b451f83174
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>
247 lines
9.7 KiB
C#
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 < 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);
|
|
}
|
|
}
|