using System.Diagnostics; using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Dungeons; using Theriapolis.Core.Tactical; using Theriapolis.Core.World; using Xunit; namespace Theriapolis.Tests.Dungeons; /// /// Phase 7 M1 — engine-level tests for the dungeon generator. These use the /// authored M0 vertical-slice content (5 imperium + 3 mine + 2 cave /// templates, 2 layouts) and assert the engine's contracts: /// - Determinism: same (seed, poi) → byte-identical Dungeon. /// - Reachability: every Room reachable from Entrance via Connections. /// - Scale: room count stays within the layout's declared band. /// - Budget: generation completes in < 400ms even under retry-fallback. /// public sealed class DungeonGeneratorTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Determinism ────────────────────────────────────────────────────── [Fact] public void Generate_SameSeedAndPoi_ProducesIdenticalDungeon() { const ulong seed = 0xCAFE12345UL; const int poi = 7; var a = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content); var b = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content); Assert.Equal(a.PoiId, b.PoiId); Assert.Equal(a.Type, b.Type); Assert.Equal(a.W, b.W); Assert.Equal(a.H, b.H); Assert.Equal(a.EntranceTile, b.EntranceTile); Assert.Equal(a.Rooms.Length, b.Rooms.Length); for (int i = 0; i < a.Rooms.Length; i++) { Assert.Equal(a.Rooms[i].TemplateId, b.Rooms[i].TemplateId); Assert.Equal(a.Rooms[i].AabbX, b.Rooms[i].AabbX); Assert.Equal(a.Rooms[i].AabbY, b.Rooms[i].AabbY); Assert.Equal(a.Rooms[i].Role, b.Rooms[i].Role); } Assert.Equal(a.Connections.Length, b.Connections.Length); for (int i = 0; i < a.Connections.Length; i++) Assert.Equal(a.Connections[i], b.Connections[i]); // Tile array byte-identical. Assert.Equal(a.W * a.H, b.W * b.H); for (int y = 0; y < a.H; y++) for (int x = 0; x < a.W; x++) { Assert.Equal(a.Tiles[x, y].Surface, b.Tiles[x, y].Surface); Assert.Equal(a.Tiles[x, y].Deco, b.Tiles[x, y].Deco); } } [Fact] public void Generate_DifferentSeed_ProducesDifferentDungeon() { var a = DungeonGenerator.Generate(0x1111UL, 5, PoiType.ImperiumRuin, _content); var b = DungeonGenerator.Generate(0x2222UL, 5, PoiType.ImperiumRuin, _content); // Same template count is fine, but at least *something* must differ. bool differs = a.W != b.W || a.H != b.H || a.Rooms.Length != b.Rooms.Length || (a.Rooms.Length == b.Rooms.Length && !RoomLayoutsMatch(a, b)); Assert.True(differs, "Different worldSeeds should produce divergent layouts (room mix or geometry)."); } [Fact] public void Generate_DifferentPoi_ProducesDifferentDungeon() { var a = DungeonGenerator.Generate(0xBEEFUL, 1, PoiType.ImperiumRuin, _content); var b = DungeonGenerator.Generate(0xBEEFUL, 2, PoiType.ImperiumRuin, _content); bool differs = a.W != b.W || a.H != b.H || a.Rooms.Length != b.Rooms.Length || (a.Rooms.Length == b.Rooms.Length && !RoomLayoutsMatch(a, b)); Assert.True(differs, "Different poiIds at the same seed should produce divergent layouts."); } private static bool RoomLayoutsMatch(Dungeon a, Dungeon b) { for (int i = 0; i < a.Rooms.Length; i++) if (a.Rooms[i].TemplateId != b.Rooms[i].TemplateId || a.Rooms[i].AabbX != b.Rooms[i].AabbX || a.Rooms[i].AabbY != b.Rooms[i].AabbY) return false; return true; } // ── Reachability ───────────────────────────────────────────────────── [Fact] public void Generate_EveryRoom_ReachableFromEntrance() { // Sample 20 (seed, poi) pairs and assert reachability for each. for (int i = 0; i < 20; i++) { ulong seed = 0x1000000UL + (ulong)i; int poi = i; var d = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content); AssertAllRoomsReachable(d); } } [Fact] public void Generate_Mine_AllRoomsReachable() { for (int i = 0; i < 10; i++) { var d = DungeonGenerator.Generate(0x70UL + (ulong)i, i, PoiType.AbandonedMine, _content); AssertAllRoomsReachable(d); } } private static void AssertAllRoomsReachable(Dungeon d) { if (d.Rooms.Length == 0) return; var adj = new List[d.Rooms.Length]; for (int i = 0; i < d.Rooms.Length; i++) adj[i] = new List(); foreach (var c in d.Connections) { adj[c.RoomA].Add(c.RoomB); adj[c.RoomB].Add(c.RoomA); } var visited = new bool[d.Rooms.Length]; var queue = new Queue(); queue.Enqueue(0); visited[0] = true; while (queue.Count > 0) { int n = queue.Dequeue(); foreach (int m in adj[n]) if (!visited[m]) { visited[m] = true; queue.Enqueue(m); } } for (int i = 0; i < d.Rooms.Length; i++) Assert.True(visited[i], $"Room {i} ({d.Rooms[i].TemplateId}) unreachable from Room 0."); } // ── Scale ──────────────────────────────────────────────────────────── [Fact] public void Generate_RoomCount_StaysWithinLayoutBand() { // imperium_medium: 6..10 rooms. for (int i = 0; i < 10; i++) { var d = DungeonGenerator.Generate(0xA0UL + (ulong)i, i, PoiType.ImperiumRuin, _content); Assert.InRange(d.Rooms.Length, C.DUNGEON_MED_ROOMS_MIN, C.DUNGEON_MED_ROOMS_MAX); } } [Fact] public void Generate_Mine_RoomCount_StaysWithinSmallBand() { // mine_small: 3..5 rooms. for (int i = 0; i < 10; i++) { var d = DungeonGenerator.Generate(0xB0UL + (ulong)i, i, PoiType.AbandonedMine, _content); Assert.InRange(d.Rooms.Length, C.DUNGEON_SMALL_ROOMS_MIN, C.DUNGEON_SMALL_ROOMS_MAX); } } // ── Budget ─────────────────────────────────────────────────────────── [Fact] public void Generate_CompletesUnderBudget() { // Under ~400ms even with the worst-case retry-then-linear-fallback // for a medium imperium ruin. var sw = Stopwatch.StartNew(); for (int i = 0; i < 10; i++) { DungeonGenerator.Generate(0xC0UL + (ulong)i, i, PoiType.ImperiumRuin, _content); } sw.Stop(); Assert.True(sw.ElapsedMilliseconds < 4000, $"10 dungeon gens should complete in <4s (per-gen <400ms target); took {sw.ElapsedMilliseconds}ms."); } // ── Tile-array sanity ──────────────────────────────────────────────── [Fact] public void Generate_TileArray_HasEntranceStairsDeco() { var d = DungeonGenerator.Generate(0x12345UL, 1, PoiType.ImperiumRuin, _content); var (ex, ey) = d.EntranceTile; Assert.InRange(ex, 0, d.W - 1); Assert.InRange(ey, 0, d.H - 1); Assert.Equal(TacticalDeco.Stairs, d.Tiles[ex, ey].Deco); } [Fact] public void Generate_TileArray_RoomInteriorsAreWalkable() { var d = DungeonGenerator.Generate(0x9999UL, 1, PoiType.ImperiumRuin, _content); // Every room's centre tile should be walkable. foreach (var r in d.Rooms) { int cx = r.AabbX + r.AabbW / 2; int cy = r.AabbY + r.AabbH / 2; Assert.True(d.Tiles[cx, cy].IsWalkable, $"Room {r.Id} ({r.TemplateId}) centre ({cx},{cy}) is not walkable: " + $"surface={d.Tiles[cx, cy].Surface} deco={d.Tiles[cx, cy].Deco}"); } } [Fact] public void Generate_TileArray_PerimeterIsBoundedByWalls() { var d = DungeonGenerator.Generate(0xDEADUL, 3, PoiType.ImperiumRuin, _content); // Outer perimeter (x=0, x=W-1, y=0, y=H-1) should never be walkable // — those tiles are the AABB padding, never carved. for (int x = 0; x < d.W; x++) { Assert.False(d.Tiles[x, 0].IsWalkable, $"top edge ({x},0) walkable"); Assert.False(d.Tiles[x, d.H - 1].IsWalkable, $"bottom edge ({x},{d.H - 1}) walkable"); } for (int y = 0; y < d.H; y++) { Assert.False(d.Tiles[0, y].IsWalkable, $"left edge (0,{y}) walkable"); Assert.False(d.Tiles[d.W - 1, y].IsWalkable, $"right edge ({d.W - 1},{y}) walkable"); } } [Fact] public void Generate_RequiredRoles_AllPresent() { // imperium_medium requires entry + boss. var d = DungeonGenerator.Generate(0x42UL, 1, PoiType.ImperiumRuin, _content); Assert.Contains(d.Rooms, r => r.Role == RoomRole.Entry); Assert.Contains(d.Rooms, r => r.Role == RoomRole.Boss); } }