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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Biome coverage sanity checks:
|
||||
/// - No seed produces a map that is >80% one biome.
|
||||
/// - Each required non-ocean biome appears in at least 1% of land tiles.
|
||||
/// </summary>
|
||||
public sealed class BiomeCoverageTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public BiomeCoverageTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
private Dictionary<BiomeId, int> CountBiomes(ulong seed)
|
||||
{
|
||||
var ctx = _cache.Get(seed);
|
||||
|
||||
var counts = new Dictionary<BiomeId, int>();
|
||||
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
var b = ctx.World.Tiles[tx, ty].Biome;
|
||||
counts.TryGetValue(b, out int c);
|
||||
counts[b] = c + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoBiomeDominatesMoreThan80Percent()
|
||||
{
|
||||
var counts = CountBiomes(0xCAFEBABEUL);
|
||||
int total = C.WORLD_WIDTH_TILES * C.WORLD_HEIGHT_TILES;
|
||||
foreach (var (biome, count) in counts)
|
||||
{
|
||||
double pct = (double)count / total;
|
||||
Assert.True(pct < 0.80,
|
||||
$"Biome {biome} covers {pct:P1} of the map (limit 80%).");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredBiomes_AppearInAtLeast1PercentOfLandTiles()
|
||||
{
|
||||
var counts = CountBiomes(0xCAFEBABEUL);
|
||||
int total = C.WORLD_WIDTH_TILES * C.WORLD_HEIGHT_TILES;
|
||||
int oceanCount = counts.GetValueOrDefault(BiomeId.Ocean);
|
||||
int landTotal = total - oceanCount;
|
||||
|
||||
// These biomes must all be present and non-trivial
|
||||
BiomeId[] required =
|
||||
{
|
||||
BiomeId.Tundra,
|
||||
BiomeId.Boreal,
|
||||
BiomeId.TemperateDeciduous,
|
||||
BiomeId.TemperateGrassland,
|
||||
BiomeId.MountainAlpine,
|
||||
BiomeId.SubtropicalForest,
|
||||
};
|
||||
|
||||
foreach (var biome in required)
|
||||
{
|
||||
int count = counts.GetValueOrDefault(biome);
|
||||
double pct = landTotal > 0 ? (double)count / landTotal : 0;
|
||||
Assert.True(pct >= 0.01,
|
||||
$"Required biome {biome} covers only {pct:P2} of land tiles (minimum 1%).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Core.World.Generation.Stages;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Addendum A §1: the coastline must have organic noise-based shape across a
|
||||
/// range of seeds after the BorderDistortionGen stage runs.
|
||||
///
|
||||
/// The validator counts maximal runs of consecutive border tiles in four line
|
||||
/// orientations (horizontal, vertical, both diagonals). Any run longer than
|
||||
/// <see cref="BorderDistortionGenStage.MaxAllowedRunLength"/> tiles is a
|
||||
/// violation — this catches both axis-aligned and diagonal ruler-straight
|
||||
/// coasts, which the previous cardinal-only 3-run detector missed.
|
||||
/// </summary>
|
||||
public sealed class BorderOrganicsTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public BorderOrganicsTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
private int ViolationsForSeed(ulong seed)
|
||||
{
|
||||
var ctx = _cache.Get(seed);
|
||||
return BorderDistortionGenStage.CountStraightViolations(ctx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_CAFEBABE_HasZeroStraightViolations()
|
||||
{
|
||||
Assert.Equal(0, ViolationsForSeed(0xCAFEBABEUL));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1UL)]
|
||||
[InlineData(42UL)]
|
||||
[InlineData(999UL)]
|
||||
[InlineData(0xDEAD_BEEFUL)]
|
||||
[InlineData(0x1234_5678UL)]
|
||||
[InlineData(0xABCD_EF01UL)]
|
||||
[InlineData(7777777UL)]
|
||||
[InlineData(0xFF00_FF00UL)]
|
||||
[InlineData(314159265UL)]
|
||||
[InlineData(271828182UL)]
|
||||
public void TenSeeds_HaveZeroStraightViolations(ulong seed)
|
||||
{
|
||||
Assert.Equal(0, ViolationsForSeed(seed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// DangerZone outputs depend on a fully-generated WorldState; this fixture
|
||||
/// uses <see cref="WorldCache"/> to amortize the ~30 s pipeline run across
|
||||
/// the file. Verifies the formula: zones increase with distance from the
|
||||
/// player-start tier-1 settlement and from roads/settlements.
|
||||
/// </summary>
|
||||
public sealed class DangerZoneTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private readonly WorldCache _cache;
|
||||
public DangerZoneTests(WorldCache cache) { _cache = cache; }
|
||||
|
||||
[Fact]
|
||||
public void Compute_StaysWithinClampedRange()
|
||||
{
|
||||
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
int x = (i * 37) % C.WORLD_WIDTH_TILES;
|
||||
int y = (i * 113) % C.WORLD_HEIGHT_TILES;
|
||||
int z = DangerZone.Compute(x, y, ctx.World);
|
||||
Assert.InRange(z, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_StartTileIsZeroOrLow()
|
||||
{
|
||||
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
|
||||
var (sx, sy) = DangerZone.ResolveStartTile(ctx.World);
|
||||
int z = DangerZone.Compute(sx, sy, ctx.World);
|
||||
// Player-start should be a low-zone area (zone 0 or 1 at most after
|
||||
// biome bonus). Bovid-cities land in grasslands; if the start lands
|
||||
// in mountainous biome the bonus pushes us to 1.
|
||||
Assert.True(z <= 2, $"Player-start zone unexpectedly high: {z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_FarFromStartIsHigherZone()
|
||||
{
|
||||
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
|
||||
var (sx, sy) = DangerZone.ResolveStartTile(ctx.World);
|
||||
|
||||
int nearZone = DangerZone.Compute(sx, sy, ctx.World);
|
||||
// 100 tiles is 2 zones' worth at C.DANGER_DIST_FROM_START_PER_ZONE = 50.
|
||||
int farX = System.Math.Min(C.WORLD_WIDTH_TILES - 1, sx + 100);
|
||||
int farY = System.Math.Min(C.WORLD_HEIGHT_TILES - 1, sy + 100);
|
||||
int farZone = DangerZone.Compute(farX, farY, ctx.World);
|
||||
Assert.True(farZone > nearZone,
|
||||
$"Far tile zone ({farZone}) should be > near tile zone ({nearZone})");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Hydrology correctness: rivers must be generated, endpoints must reach water.
|
||||
/// </summary>
|
||||
public sealed class HydrologyTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private const ulong TestSeed = 0xCAFEBABEUL;
|
||||
|
||||
// HydrologyGen is stage 10 → 0-based index 9 (fast-path for hydrology-only tests).
|
||||
private const int HydrologyStageIndex = 9;
|
||||
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public HydrologyTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_GeneratesAtLeastOneRiver()
|
||||
{
|
||||
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
|
||||
Assert.NotEmpty(ctx.World.Rivers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllRivers_HaveAtLeastTwoPoints()
|
||||
{
|
||||
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
|
||||
foreach (var river in ctx.World.Rivers)
|
||||
Assert.True(river.Points.Count >= 2,
|
||||
$"River {river.Id} has fewer than 2 points");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RiverPolylines_AreInWorldPixelSpace()
|
||||
{
|
||||
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
|
||||
float maxCoord = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS;
|
||||
foreach (var river in ctx.World.Rivers)
|
||||
foreach (var pt in river.Points)
|
||||
{
|
||||
Assert.True(pt.X >= 0 && pt.X <= maxCoord,
|
||||
$"River {river.Id} point X={pt.X} out of world-pixel range");
|
||||
Assert.True(pt.Y >= 0 && pt.Y <= maxCoord,
|
||||
$"River {river.Id} point Y={pt.Y} out of world-pixel range");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xCAFEBABEUL)]
|
||||
[InlineData(0x12345678UL)]
|
||||
[InlineData(0xDEADBEEFUL)]
|
||||
public void RiverEndpoints_DrainToWaterOrWorldEdge(ulong seed)
|
||||
{
|
||||
var ctx = _cache.GetThrough(seed, HydrologyStageIndex);
|
||||
var world = ctx.World;
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
int nonDrainingCount = 0;
|
||||
foreach (var river in world.Rivers)
|
||||
{
|
||||
if (river.Points.Count < 2) continue;
|
||||
var last = river.Points[^1];
|
||||
int lx = Math.Clamp((int)(last.X / C.WORLD_TILE_PIXELS), 0, W - 1);
|
||||
int ly = Math.Clamp((int)(last.Y / C.WORLD_TILE_PIXELS), 0, H - 1);
|
||||
|
||||
var biome = world.Tiles[lx, ly].Biome;
|
||||
bool atWater = biome == BiomeId.Ocean || biome == BiomeId.Wetland ||
|
||||
(world.Tiles[lx, ly].Features & FeatureFlags.HasRiver) != 0;
|
||||
bool atEdge = lx == 0 || ly == 0 || lx == W - 1 || ly == H - 1;
|
||||
|
||||
if (!atWater && !atEdge)
|
||||
nonDrainingCount++;
|
||||
}
|
||||
|
||||
// Allow up to 10% non-draining rivers (noise from complex terrain)
|
||||
double failRate = world.Rivers.Count > 0
|
||||
? (double)nonDrainingCount / world.Rivers.Count
|
||||
: 0.0;
|
||||
Assert.True(failRate <= 0.10,
|
||||
$"Seed {seed:X}: {nonDrainingCount}/{world.Rivers.Count} rivers do not drain to water ({failRate:P0})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncounterDensityMap_IsPopulated()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
Assert.NotNull(ctx.World.EncounterDensity);
|
||||
|
||||
float sum = 0;
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
sum += ctx.World.EncounterDensity![x, y];
|
||||
Assert.True(sum > 0, "EncounterDensity map is all zeros");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Linear feature exclusion (Addendum A §2): no parallel river+rail or rail+road
|
||||
/// on the same non-settlement tile. Road+river is allowed — it represents a
|
||||
/// bridge crossing (see ValidationPassStage.CheckLinearExclusion).
|
||||
/// </summary>
|
||||
public sealed class LinearFeatureTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public LinearFeatureTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xCAFEBABEUL)]
|
||||
[InlineData(0x11223344UL)]
|
||||
[InlineData(0xDEADBEEFUL)]
|
||||
[InlineData(0xFEEDFACEUL)]
|
||||
[InlineData(0xABCDEF00UL)]
|
||||
[InlineData(0x00112233UL)]
|
||||
[InlineData(0x99AABBCCUL)]
|
||||
[InlineData(0x12345678UL)]
|
||||
[InlineData(0x87654321UL)]
|
||||
[InlineData(0x0DEADC0DUL)]
|
||||
public void NoParallelLinearFeatures_OnNonSettlementTiles(ulong seed)
|
||||
{
|
||||
var ctx = _cache.Get(seed);
|
||||
var world = ctx.World;
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
int violations = 0;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
|
||||
bool hasRiver = (tile.Features & FeatureFlags.HasRiver) != 0;
|
||||
bool hasRail = (tile.Features & FeatureFlags.HasRail) != 0;
|
||||
bool hasRoad = (tile.Features & FeatureFlags.HasRoad) != 0;
|
||||
|
||||
// River + Rail parallel
|
||||
if (hasRiver && hasRail &&
|
||||
tile.RiverFlowDir != Theriapolis.Core.Util.Dir.None &&
|
||||
tile.RailDir != Theriapolis.Core.Util.Dir.None &&
|
||||
Theriapolis.Core.Util.Dir.IsParallel(tile.RiverFlowDir, tile.RailDir))
|
||||
violations++;
|
||||
|
||||
// Rail + Road (always a violation outside settlements)
|
||||
if (hasRail && hasRoad)
|
||||
violations++;
|
||||
}
|
||||
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoadNetwork_HasAtLeastOneRoad()
|
||||
{
|
||||
var ctx = _cache.Get(0xCAFEBABEUL);
|
||||
Assert.NotEmpty(ctx.World.Roads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RailNetwork_HasAtLeastOneRailLine()
|
||||
{
|
||||
if (!C.ENABLE_RAIL) return; // rail subsystem disabled; nothing to assert
|
||||
var ctx = _cache.Get(0xCAFEBABEUL);
|
||||
Assert.NotEmpty(ctx.World.Rails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roads_AreInWorldPixelSpace()
|
||||
{
|
||||
var ctx = _cache.Get(0xCAFEBABEUL);
|
||||
float maxCoord = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS;
|
||||
foreach (var road in ctx.World.Roads)
|
||||
foreach (var pt in road.Points)
|
||||
{
|
||||
Assert.InRange(pt.X, 0f, maxCoord);
|
||||
Assert.InRange(pt.Y, 0f, maxCoord);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationPassHash_RecordsZeroViolations()
|
||||
{
|
||||
var ctx = _cache.Get(0xCAFEBABEUL);
|
||||
if (!ctx.World.StageHashes.TryGetValue("ValidationPass", out ulong vhash))
|
||||
return; // stage didn't record hash — skip
|
||||
|
||||
int violations = (int)(vhash / 1000);
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Macro-template elevation and moisture constraints must hold after generation.
|
||||
/// Tests from the Phase 1 acceptance criteria.
|
||||
/// </summary>
|
||||
public sealed class MacroConstraintTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private const ulong TestSeed = 0xCAFEBABEUL;
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public MacroConstraintTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
[Fact]
|
||||
public void Mountain_Cells_HaveElevation_AboveFloor()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
|
||||
int violations = 0;
|
||||
|
||||
for (int ty = 0; ty < H; ty += 4)
|
||||
for (int tx = 0; tx < W; tx += 4)
|
||||
{
|
||||
ref var tile = ref ctx.World.TileAt(tx, ty);
|
||||
var cell = ctx.World.MacroCellForTile(tile);
|
||||
// Skip ocean tiles — they're forced below sea level regardless of macro constraint
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
if (cell.BiomeType.Contains("mountain", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tile.Elevation < cell.ElevationFloor - 0.05f) // 5% tolerance for blending
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grassland_Cells_HaveElevation_BelowCeiling()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
|
||||
int violations = 0;
|
||||
|
||||
for (int ty = 0; ty < H; ty += 4)
|
||||
for (int tx = 0; tx < W; tx += 4)
|
||||
{
|
||||
ref var tile = ref ctx.World.TileAt(tx, ty);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
var cell = ctx.World.MacroCellForTile(tile);
|
||||
if (cell.BiomeType.Contains("grassland", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tile.Elevation > cell.ElevationCeiling + 0.05f)
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tundra_Cells_HaveMoisture_BelowCeiling()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
|
||||
int violations = 0;
|
||||
|
||||
for (int ty = 0; ty < H; ty += 4)
|
||||
for (int tx = 0; tx < W; tx += 4)
|
||||
{
|
||||
ref var tile = ref ctx.World.TileAt(tx, ty);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
var cell = ctx.World.MacroCellForTile(tile);
|
||||
if (cell.BiomeType.Contains("tundra", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tile.Moisture > cell.MoistureCeiling + 0.05f)
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subtropical_Cells_HaveMoisture_AboveFloor()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
|
||||
int violations = 0;
|
||||
|
||||
for (int ty = 0; ty < H; ty += 4)
|
||||
for (int tx = 0; tx < W; tx += 4)
|
||||
{
|
||||
ref var tile = ref ctx.World.TileAt(tx, ty);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
var cell = ctx.World.MacroCellForTile(tile);
|
||||
if (cell.BiomeType.Contains("subtropical", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tile.Moisture < cell.MoistureFloor - 0.05f)
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
Assert.Equal(0, violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Worldgen;
|
||||
|
||||
/// <summary>
|
||||
/// Settlement placement correctness: narrative anchors, tier counts, distances,
|
||||
/// reachability, and no overlaps.
|
||||
/// </summary>
|
||||
public sealed class SettlementTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private const ulong TestSeed = 0xCAFEBABEUL;
|
||||
private readonly WorldCache _cache;
|
||||
|
||||
public SettlementTests(WorldCache cache) => _cache = cache;
|
||||
|
||||
// ── Narrative anchors ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SanctumFidelis_IsPresent()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
var capital = ctx.World.Settlements
|
||||
.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
|
||||
Assert.NotNull(capital);
|
||||
Assert.Equal(1, capital!.Tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllNarrativeAnchors_ArePlaced()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
var anchors = ctx.World.Settlements
|
||||
.Where(s => s.Anchor.HasValue)
|
||||
.Select(s => s.Anchor!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (NarrativeAnchor anchor in Enum.GetValues<NarrativeAnchor>())
|
||||
Assert.Contains(anchor, anchors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xCAFEBABEUL)]
|
||||
[InlineData(0x11111111UL)]
|
||||
[InlineData(0x99887766UL)]
|
||||
[InlineData(0xABCDEF01UL)]
|
||||
[InlineData(0x00000042UL)]
|
||||
public void NarrativeAnchors_PlacedAcrossMultipleSeeds(ulong seed)
|
||||
{
|
||||
var ctx = _cache.Get(seed);
|
||||
var capital = ctx.World.Settlements
|
||||
.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
|
||||
Assert.NotNull(capital);
|
||||
}
|
||||
|
||||
// ── Tier counts ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TierCounts_MeetMinimums()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
var ss = ctx.World.Settlements.Where(s => !s.IsPoi).ToList();
|
||||
|
||||
int tier1 = ss.Count(s => s.Tier == 1);
|
||||
int tier2 = ss.Count(s => s.Tier == 2);
|
||||
int tier3 = ss.Count(s => s.Tier == 3);
|
||||
int tier4 = ss.Count(s => s.Tier == 4);
|
||||
|
||||
Assert.Equal(1, tier1); // exactly one capital
|
||||
Assert.InRange(tier2, C.SETTLE_TIER2_MIN, C.SETTLE_TIER2_MAX);
|
||||
Assert.InRange(tier3, C.SETTLE_TIER3_MIN, C.SETTLE_TIER3_MAX);
|
||||
Assert.True(tier4 >= C.SETTLE_TIER4_MIN,
|
||||
$"Only {tier4} tier-4 settlements, need at least {C.SETTLE_TIER4_MIN}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PoICount_MeetsMinimum()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int pois = ctx.World.Settlements.Count(s => s.IsPoi);
|
||||
Assert.True(pois >= C.SETTLE_TIER5_MIN,
|
||||
$"Only {pois} PoIs, need at least {C.SETTLE_TIER5_MIN}");
|
||||
}
|
||||
|
||||
// ── No overlapping settlements ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NoSettlements_ShareTheSameTile()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
var positions = new HashSet<(int, int)>();
|
||||
foreach (var s in ctx.World.Settlements)
|
||||
{
|
||||
bool added = positions.Add((s.TileX, s.TileY));
|
||||
Assert.True(added,
|
||||
$"Two settlements share tile ({s.TileX},{s.TileY})");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimum separation ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Tier1And2Settlements_MeetMinimumDistance()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
var high = ctx.World.Settlements.Where(s => s.Tier <= 2 && !s.IsPoi).ToList();
|
||||
int minSq = C.SETTLE_MIN_DIST_TIER2 * C.SETTLE_MIN_DIST_TIER2;
|
||||
|
||||
for (int i = 0; i < high.Count; i++)
|
||||
for (int j = i + 1; j < high.Count; j++)
|
||||
{
|
||||
int dx = high[i].TileX - high[j].TileX;
|
||||
int dy = high[i].TileY - high[j].TileY;
|
||||
Assert.True(dx * dx + dy * dy >= minSq,
|
||||
$"{high[i].Name} and {high[j].Name} are too close ({Math.Sqrt(dx*dx+dy*dy):F0} tiles, min {C.SETTLE_MIN_DIST_TIER2})");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settlement attributes ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllSettlements_HaveNames()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
foreach (var s in ctx.World.Settlements.Where(s => !s.IsPoi))
|
||||
Assert.False(string.IsNullOrWhiteSpace(s.Name),
|
||||
$"Settlement at ({s.TileX},{s.TileY}) Tier {s.Tier} has no name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSettlements_HaveValidTileCoordinates()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
foreach (var s in ctx.World.Settlements)
|
||||
{
|
||||
Assert.InRange(s.TileX, 0, W - 1);
|
||||
Assert.InRange(s.TileY, 0, H - 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoSettlement_PlacedOnOcean()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
foreach (var s in ctx.World.Settlements)
|
||||
{
|
||||
var biome = ctx.World.Tiles[s.TileX, s.TileY].Biome;
|
||||
Assert.NotEqual(BiomeId.Ocean, biome);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Faction influence ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FactionInfluence_IsComputed()
|
||||
{
|
||||
var ctx = _cache.Get(TestSeed);
|
||||
Assert.NotNull(ctx.World.FactionInfluence);
|
||||
|
||||
// Capital area should have high Enforcer influence
|
||||
var capital = ctx.World.Settlements
|
||||
.First(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
|
||||
float enforcer = ctx.World.FactionInfluence!
|
||||
.Get((int)FactionId.CovenantEnforcers, capital.TileX, capital.TileY);
|
||||
Assert.True(enforcer > 0.5f,
|
||||
$"Enforcer influence at capital is only {enforcer:F3}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user