using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Tactical; using Theriapolis.Core.World; using Theriapolis.Core.World.Settlements; using Xunit; namespace Theriapolis.Tests.Settlements; /// /// Phase 6 M0 — building stamp determinism + content shape tests. /// /// The settlement-stamping path picks up content lazily on the first chunk /// that touches each settlement; identical seeds must produce identical /// building lists, and the stamped tile bytes must round-trip across two /// independent generations. /// public sealed class BuildingStampTests : IClassFixture { private const ulong TestSeed = 0xCAFEBABEUL; private readonly WorldCache _cache; public BuildingStampTests(WorldCache c) => _cache = c; private SettlementContent LoadContent() => new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Settlements; [Fact] public void ContentLoader_LoadsBuildingsAndLayouts() { var content = LoadContent(); Assert.True(content.Buildings.Count >= 6, $"expected ≥ 6 building templates, got {content.Buildings.Count}"); Assert.True(content.PresetByAnchor.Count >= 1, "expected at least one preset settlement layout"); Assert.True(content.ProceduralByTier.Count >= 4, "expected procedural layouts for Tier 2/3/4/5"); // Sanity: every preset must reference real building templates. foreach (var p in content.PresetByAnchor.Values) foreach (var b in p.Buildings) Assert.True(content.Buildings.ContainsKey(b.Template), $"preset '{p.Id}' references unknown template '{b.Template}'"); } [Fact] public void Stamp_ProducesIdenticalBuildingsAcrossRuns() { var w1 = _cache.Get(TestSeed, variant: 0).World; var w2 = _cache.Get(TestSeed, variant: 1).World; var content = LoadContent(); var s1 = w1.Settlements.First(s => !s.IsPoi && s.Tier <= 3); var s2 = w2.Settlements.First(s => s.Id == s1.Id); SettlementStamper.EnsureBuildingsResolved(TestSeed, s1, content); SettlementStamper.EnsureBuildingsResolved(TestSeed, s2, content); Assert.Equal(s1.Buildings.Count, s2.Buildings.Count); for (int i = 0; i < s1.Buildings.Count; i++) { Assert.Equal(s1.Buildings[i].TemplateId, s2.Buildings[i].TemplateId); Assert.Equal(s1.Buildings[i].MinX, s2.Buildings[i].MinX); Assert.Equal(s1.Buildings[i].MinY, s2.Buildings[i].MinY); Assert.Equal(s1.Buildings[i].MaxX, s2.Buildings[i].MaxX); Assert.Equal(s1.Buildings[i].MaxY, s2.Buildings[i].MaxY); } } [Fact] public void Stamp_ProducesIdenticalChunkHashWithSameContent() { var w = _cache.Get(TestSeed).World; var content = LoadContent(); var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3); var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY); var a = TacticalChunkGen.Generate(TestSeed, cc, w, content); // Re-resolve buildings from a fresh world to make sure the stamper // is idempotent (BuildingsResolved guard works). var w2 = _cache.Get(TestSeed, variant: 1).World; var b = TacticalChunkGen.Generate(TestSeed, cc, w2, content); Assert.Equal(a.Hash(), b.Hash()); } [Fact] public void Stamp_ContentNullFallsBackToLegacyHash() { // Generating a chunk without content should match a chunk generated // with the no-content overload — i.e., the fallback path is the // Phase-4 placeholder behaviour, byte-for-byte. var w = _cache.Get(TestSeed).World; var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3); var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY); var a = TacticalChunkGen.Generate(TestSeed, cc, w); var b = TacticalChunkGen.Generate(TestSeed, cc, w, settlementContent: null); Assert.Equal(a.Hash(), b.Hash()); } [Fact] public void StampedSettlement_HasMoreBuildingTilesThanFallback() { // The whole point of M0 — content path stamps Floor tiles inside // building footprints, fallback only stamps Cobble. Floor count // diverges; this captures that. var w = _cache.Get(TestSeed).World; var content = LoadContent(); var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3); var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY); var withContent = TacticalChunkGen.Generate(TestSeed, cc, w, content); var w2 = _cache.Get(TestSeed, variant: 1).World; var without = TacticalChunkGen.Generate(TestSeed, cc, w2, settlementContent: null); int floorWith = CountSurface(withContent, TacticalSurface.Floor); int floorWithout = CountSurface(without, TacticalSurface.Floor); Assert.True(floorWith > floorWithout, $"Content-aware stamp should produce floor tiles; got {floorWith} vs {floorWithout}."); } [Fact] public void Buildings_HaveDoorsAndDoorsAreWalkable() { var w = _cache.Get(TestSeed).World; var content = LoadContent(); var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3); SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content); Assert.NotEmpty(s.Buildings); // Render a chunk that overlaps each building and confirm the door // tile is walkable + carries the Doorway flag. foreach (var b in s.Buildings) { Assert.NotEmpty(b.Doors); foreach (var (dx, dy) in b.Doors) { var cc = new ChunkCoord( dx / C.TACTICAL_CHUNK_SIZE - (dx < 0 ? 1 : 0), dy / C.TACTICAL_CHUNK_SIZE - (dy < 0 ? 1 : 0)); var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content); int lx = dx - chunk.OriginX; int ly = dy - chunk.OriginY; Assert.InRange(lx, 0, C.TACTICAL_CHUNK_SIZE - 1); Assert.InRange(ly, 0, C.TACTICAL_CHUNK_SIZE - 1); ref var tile = ref chunk.Tiles[lx, ly]; Assert.True(tile.IsWalkable, $"door at ({dx},{dy}) for building {b.TemplateId} should be walkable"); Assert.True((tile.Flags & (byte)TacticalFlags.Doorway) != 0, "door tile must carry the Doorway flag"); Assert.True((tile.Flags & (byte)TacticalFlags.Building) != 0, "door tile must carry the Building flag"); } } } [Fact] public void MillhavenAnchor_GetsItsPresetLayout() { // We can't guarantee the Millhaven anchor exists at every test seed // (placement depends on world geometry). When it does, it should // resolve to the preset layout, not the procedural Tier-1 fallback. var w = _cache.Get(TestSeed).World; var content = LoadContent(); var millhaven = w.Settlements.FirstOrDefault( s => s.Anchor is NarrativeAnchor.Millhaven); if (millhaven is null) return; var layout = content.ResolveFor(millhaven); Assert.NotNull(layout); Assert.Equal("preset", layout!.Kind); Assert.Equal("Millhaven", layout.Anchor); } [Fact] public void ProceduralLayouts_StampMultipleBuildings() { // Pick a non-anchor Tier 2 or 3 settlement and confirm the // procedural roller produced more than one building. Sanity that // the weighted picker doesn't collapse to zero. var w = _cache.Get(TestSeed).World; var content = LoadContent(); var s = w.Settlements.FirstOrDefault( x => x.Anchor is null && !x.IsPoi && x.Tier is 2 or 3); if (s is null) return; SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content); Assert.True(s.Buildings.Count >= 2, $"procedural Tier-{s.Tier} settlement should stamp ≥ 2 buildings, got {s.Buildings.Count}"); } [Fact] public void ResidentSpawns_AppearInChunkSpawnList() { var w = _cache.Get(TestSeed).World; var content = LoadContent(); // Find any settlement at Tier ≤ 3 whose layout has roles. var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3); SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content); // Sum role count across buildings. int expectedRoles = s.Buildings.Sum(b => b.Residents.Length); if (expectedRoles == 0) return; // Generate every chunk overlapping any building and count Resident // spawn records emitted. int actual = 0; var seen = new HashSet(); foreach (var b in s.Buildings) { int minCx = (int)Math.Floor(b.MinX / (double)C.TACTICAL_CHUNK_SIZE); int minCy = (int)Math.Floor(b.MinY / (double)C.TACTICAL_CHUNK_SIZE); int maxCx = (int)Math.Floor(b.MaxX / (double)C.TACTICAL_CHUNK_SIZE); int maxCy = (int)Math.Floor(b.MaxY / (double)C.TACTICAL_CHUNK_SIZE); for (int cy = minCy; cy <= maxCy; cy++) for (int cx = minCx; cx <= maxCx; cx++) { var cc = new ChunkCoord(cx, cy); if (!seen.Add(cc)) continue; var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content); foreach (var sp in chunk.Spawns) if (sp.Kind == SpawnKind.Resident) actual++; } } Assert.Equal(expectedRoles, actual); } private static int CountSurface(TacticalChunk chunk, TacticalSurface surface) { int n = 0; for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++) for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++) if (chunk.Tiles[x, y].Surface == surface) n++; return n; } }