Files
TheriapolisV3/Theriapolis.Tests/Dungeons/DungeonGeneratorTests.cs
T
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

247 lines
9.7 KiB
C#

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;
/// <summary>
/// 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 &lt; 400ms even under retry-fallback.
/// </summary>
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<int>[d.Rooms.Length];
for (int i = 0; i < d.Rooms.Length; i++) adj[i] = new List<int>();
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<int>();
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);
}
}