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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,109 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — verifies the headless level-up loop the
/// <c>character-roll --level N</c> Tools flag uses. The Tools command
/// re-creates this loop in <see cref="Theriapolis.Tools.Commands.CharacterRoll"/>;
/// this test asserts the API contract works deterministically without
/// invoking the Tools assembly directly.
/// </summary>
public sealed class CharacterRollLevelFlagTests
{
private static (Character pc, IReadOnlyDictionary<string, SubclassDef> subs) BuildBase()
{
var loader = new ContentLoader(TestHelpers.DataDirectory);
var content = new ContentResolver(loader);
var b = new CharacterBuilder
{
Clade = content.Clades["canidae"],
Species = content.Species["wolf"],
ClassDef = content.Classes["fangsworn"],
Background = content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
Name = "Test",
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
Assert.True(b.Validate(out _));
return (b.Build(content.Items), content.Subclasses);
}
private static Character LevelTo(int target, ulong worldSeed = 12345UL, ulong msOverride = 0UL)
{
var (pc, subs) = BuildBase();
for (int lv = 2; lv <= target; lv++)
{
ulong seed = worldSeed ^ msOverride ^ C.RNG_LEVELUP ^ (ulong)lv;
var result = LevelUpFlow.Compute(pc, lv, seed, takeAverage: true, subclasses: subs);
var choices = new LevelUpChoices
{
TakeAverageHp = true,
SubclassId = result.GrantsSubclassChoice && pc.ClassDef.SubclassIds.Length > 0
? pc.ClassDef.SubclassIds[0]
: null,
};
if (result.GrantsAsiChoice)
choices.AsiAdjustments[AbilityId.CON] = 2;
pc.ApplyLevelUp(result, choices);
}
return pc;
}
[Fact]
public void LevelN_ProducesExpectedLevelAndProficiency()
{
var pc1 = LevelTo(1);
Assert.Equal(1, pc1.Level);
Assert.Equal(2, pc1.ProficiencyBonus);
var pc5 = LevelTo(5);
Assert.Equal(5, pc5.Level);
Assert.Equal(3, pc5.ProficiencyBonus);
var pc11 = LevelTo(11);
Assert.Equal(11, pc11.Level);
Assert.Equal(4, pc11.ProficiencyBonus);
}
[Fact]
public void LevelN_PicksSubclassAtLevelThree()
{
var pc3 = LevelTo(3);
Assert.Equal(3, pc3.Level);
Assert.False(string.IsNullOrEmpty(pc3.SubclassId),
"level-3 character must have a subclass selected");
}
[Fact]
public void LevelN_AppliesAsiAtLevelFour()
{
// Auto-pilot ASI puts +2 to CON at level 4 (one of the C.ASI_LEVELS).
// Compare CON pre/post — clade + species mods are baked in by the
// builder, so absolute values vary by build choices but the
// delta is exactly 2.
var pc3 = LevelTo(3);
var pc4 = LevelTo(4);
Assert.Equal(pc3.Abilities.CON + 2, pc4.Abilities.CON);
}
[Fact]
public void LevelN_IsDeterministic()
{
var a = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
var b = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
Assert.Equal(a.Level, b.Level);
Assert.Equal(a.MaxHp, b.MaxHp);
Assert.Equal(a.SubclassId, b.SubclassId);
}
}
@@ -0,0 +1,160 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — clade-responsive movement multiplier tests. Verifies the
/// table from Phase 7 plan §5.4 and that hybrid PCs use the dominant-
/// lineage's presenting size for the lookup.
/// </summary>
public sealed class ClademorphicMovementTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Plan §5.4 table values ────────────────────────────────────────────
[Fact]
public void LargePc_InMustelidTunnel_PaysHeavyMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_HEAVY,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "mustelid"));
}
[Fact]
public void MediumLargePc_InMustelidTunnel_PaysMediumMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.MediumLarge, "mustelid"));
}
[Fact]
public void MediumPc_InMustelidTunnel_PaysLightMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Medium, "mustelid"));
}
[Fact]
public void SmallPc_InMustelidTunnel_NoPenalty()
{
Assert.Equal(1.0f,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "mustelid"));
}
[Fact]
public void SmallPc_InUrsidHall_PaysExposedMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "ursid"));
}
[Fact]
public void LargePc_InCervidHall_PaysAntlerClearancePenalty()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "cervid"));
}
[Fact]
public void AnyPc_InImperiumOrNoneRoom_NoPenalty()
{
foreach (var size in new[] { SizeCategory.Small, SizeCategory.Medium, SizeCategory.MediumLarge, SizeCategory.Large })
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "imperium"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "none"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, ""));
}
}
[Fact]
public void UnknownBuiltBy_NoPenalty()
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "garbage"));
}
// ── Hybrid PC presenting-size lookup ─────────────────────────────────
[Fact]
public void HybridPc_UsesPresentingClade_ForSizeLookup()
{
// Build a Wolf-Folk × Hare-Folk hybrid presenting as Hare-Folk
// (Dam dominant). EffectiveSize should be the presenting species' size.
var pc = BuildHybrid(
sireClade: "canidae", sireSpecies: "wolf",
damClade: "leporidae", damSpecies: "hare",
dominant: ParentLineage.Dam);
// The hybrid build path picked Hare-Folk as the presenting species,
// so EffectiveSize should match Character.Size (which the builder
// already set to Hare-Folk's size category).
Assert.Equal(pc.Size, ClademorphicMovement.EffectiveSize(pc));
// Whichever species the builder chose, the multiplier should match
// its size's lookup in a Mustelid tunnel.
var expected = ClademorphicMovement.GetCostMultiplier(pc.Size, "mustelid");
Assert.Equal(expected, ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
[Fact]
public void NonHybridPc_UsesOwnSize_ForLookup()
{
var pc = BuildPurebred("canidae", "wolf"); // wolf = MediumLarge
Assert.Equal(SizeCategory.MediumLarge, pc.Size);
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
// ── Helpers ──────────────────────────────────────────────────────────
private Character BuildPurebred(string cladeId, string speciesId)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
return b.Build(_content.Items);
}
private Character BuildHybrid(
string sireClade, string sireSpecies,
string damClade, string damSpecies,
ParentLineage dominant)
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades[sireClade],
HybridSireSpecies = _content.Species[sireSpecies],
HybridDamClade = _content.Clades[damClade],
HybridDamSpecies = _content.Species[damSpecies],
HybridDominantParent = dominant,
};
// Chosen skills don't matter for size-of-character — pick first N.
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
Assert.True(b.TryBuildHybrid(_content.Items, out var character, out _));
return character!;
}
}
@@ -0,0 +1,185 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — central consumable-dispatch tests + Phase 6.5 M4 carryover
/// (Hybrid Medical Incompatibility scaling on healing potions).
/// </summary>
public sealed class ConsumableHandlerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Healing potion ───────────────────────────────────────────────────
[Fact]
public void Consume_HealingPotion_RestoresHp()
{
var pc = MakePurebred();
pc.CurrentHp = 1;
var potion = _content.Items["healing_potion"];
var result = ConsumableHandler.Consume(potion, pc, seed: 0xFEEDUL);
Assert.True(result.IsSuccess);
Assert.Equal(ConsumeResult.ResultKind.Healed, result.Kind);
Assert.True(result.HealedAmount >= 4, // 2d4+2 minimum = 4
$"healing potion should heal ≥ 4 HP, healed {result.HealedAmount}");
Assert.True(pc.CurrentHp > 1);
}
[Fact]
public void Consume_HealingPotion_OnHybridPc_AppliesMedicalIncompatibility()
{
var pcPure = MakePurebred();
var pcHybrid = MakeHybrid();
// Pin both at 1 HP; same seed so the dice are identical.
pcPure.CurrentHp = 1;
pcHybrid.CurrentHp = 1;
// Boost MaxHp so neither caps to MaxHp.
pcPure.MaxHp = 100;
pcHybrid.MaxHp = 100;
var potion = _content.Items["healing_potion"];
var pureResult = ConsumableHandler.Consume(potion, pcPure, seed: 42UL);
var hybridResult = ConsumableHandler.Consume(potion, pcHybrid, seed: 42UL);
// Hybrid should heal 75% (round down, min 1) of the same dice roll.
// 0.75 * 4 = 3, 0.75 * 8 = 6, 0.75 * 10 = 7. So hybrid amount < pure
// amount whenever the pure roll is ≥ 4 (which 2d4+2 always is).
Assert.True(hybridResult.IsSuccess);
Assert.True(hybridResult.WasScaledForHybrid);
Assert.True(hybridResult.HealedAmount < pureResult.HealedAmount,
$"hybrid {hybridResult.HealedAmount} should be < pure {pureResult.HealedAmount}");
Assert.True(hybridResult.HealedAmount >= 1, "min-heal floor is 1 even for hybrids");
}
[Fact]
public void Consume_HealingPotion_DeterministicForSameSeed()
{
var pcA = MakePurebred(); pcA.CurrentHp = 1; pcA.MaxHp = 100;
var pcB = MakePurebred(); pcB.CurrentHp = 1; pcB.MaxHp = 100;
var potion = _content.Items["healing_potion"];
var a = ConsumableHandler.Consume(potion, pcA, seed: 0xC0FFEEUL);
var b = ConsumableHandler.Consume(potion, pcB, seed: 0xC0FFEEUL);
Assert.Equal(a.HealedAmount, b.HealedAmount);
}
// ── Scent masks ──────────────────────────────────────────────────────
[Fact]
public void Consume_ScentMaskBasic_OnHybridPc_SetsBasicTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_basic"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.True(result.IsSuccess);
Assert.Equal(ConsumeResult.ResultKind.MaskApplied, result.Kind);
Assert.Equal(ScentMaskTier.Basic, result.MaskTier);
Assert.True(result.MaskHadEffect);
Assert.Equal(ScentMaskTier.Basic, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMaskMilitary_OnHybridPc_SetsMilitaryTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_military"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.Equal(ScentMaskTier.Military, result.MaskTier);
Assert.Equal(ScentMaskTier.Military, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMaskDeepCover_OnHybridPc_SetsDeepCoverTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_deep_cover"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.Equal(ScentMaskTier.DeepCover, result.MaskTier);
Assert.Equal(ScentMaskTier.DeepCover, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMask_OnPurebredPc_SucceedsWithoutEffect()
{
var pc = MakePurebred();
var mask = _content.Items["scent_mask_basic"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.True(result.IsSuccess);
Assert.False(result.MaskHadEffect);
}
// ── Rejection paths ──────────────────────────────────────────────────
[Fact]
public void Consume_NonConsumable_Rejects()
{
var pc = MakePurebred();
var weapon = _content.Items["fang_knife"]; // kind = "weapon"
var result = ConsumableHandler.Consume(weapon, pc, seed: 0);
Assert.Equal(ConsumeResult.ResultKind.Rejected, result.Kind);
}
[Fact]
public void Consume_UnknownConsumableKind_ReturnsUnrecognized()
{
var pc = MakePurebred();
var unknown = new ItemDef { Id = "fake_consumable", Kind = "consumable", ConsumableKind = "tea_party" };
var result = ConsumableHandler.Consume(unknown, pc, seed: 0);
Assert.Equal(ConsumeResult.ResultKind.Unrecognized, result.Kind);
Assert.Equal("fake_consumable", result.UnrecognizedItemId);
}
// ── Helpers ──────────────────────────────────────────────────────────
private Character MakePurebred()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
return b.Build(_content.Items);
}
private Character MakeHybrid()
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades["canidae"],
HybridSireSpecies = _content.Species["wolf"],
HybridDamClade = _content.Clades["leporidae"],
HybridDamSpecies = _content.Species["hare"],
HybridDominantParent = ParentLineage.Sire,
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
Assert.True(b.TryBuildHybrid(_content.Items, out var ch, out _));
return ch!;
}
}
@@ -0,0 +1,246 @@
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);
}
}
@@ -0,0 +1,134 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — populator tests. Verifies:
/// - The same (seed, poi, levelBand) → byte-identical population.
/// - Encounter slots resolve to the per-dungeon-type templates.
/// - Boss-role rooms use the type's Boss template.
/// - Container slots pre-roll loot from the layout's loot-band table.
/// </summary>
public sealed class DungeonPopulatorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
private DungeonPopulation Populate(ulong seed, int poi, PoiType type, int levelBand)
{
var d = DungeonGenerator.Generate(seed, poi, type, _content);
// Find the matching layout (procedural; not anchor-locked).
DungeonLayoutDef? layout = null;
foreach (var l in _content.DungeonLayouts.Values)
if (string.IsNullOrEmpty(l.Anchor)
&& string.Equals(l.DungeonType, type.ToString(), System.StringComparison.OrdinalIgnoreCase))
{ layout = l; break; }
Assert.NotNull(layout);
return DungeonPopulator.Populate(d, layout!, _content, levelBand, seed);
}
[Fact]
public void Populate_SameInputs_ProducesIdenticalPopulation()
{
var a = Populate(0xCAFE12345UL, 7, PoiType.ImperiumRuin, levelBand: 2);
var b = Populate(0xCAFE12345UL, 7, PoiType.ImperiumRuin, levelBand: 2);
Assert.Equal(a.Spawns.Length, b.Spawns.Length);
for (int i = 0; i < a.Spawns.Length; i++)
{
Assert.Equal(a.Spawns[i].RoomId, b.Spawns[i].RoomId);
Assert.Equal(a.Spawns[i].X, b.Spawns[i].X);
Assert.Equal(a.Spawns[i].Y, b.Spawns[i].Y);
Assert.Equal(a.Spawns[i].Template.Id, b.Spawns[i].Template.Id);
Assert.Equal(a.Spawns[i].Kind, b.Spawns[i].Kind);
}
Assert.Equal(a.Containers.Length, b.Containers.Length);
for (int i = 0; i < a.Containers.Length; i++)
{
Assert.Equal(a.Containers[i].TableId, b.Containers[i].TableId);
Assert.Equal(a.Containers[i].Drops.Length, b.Containers[i].Drops.Length);
for (int j = 0; j < a.Containers[i].Drops.Length; j++)
Assert.Equal(a.Containers[i].Drops[j].Def.Id, b.Containers[i].Drops[j].Def.Id);
}
}
[Fact]
public void Populate_EncounterSlots_ResolveToTypeTemplates()
{
var pop = Populate(0x42UL, 1, PoiType.ImperiumRuin, levelBand: 2);
// Imperium templates: imperium_undead_thrall (PoiGuard),
// imperium_feral_canid (WildAnimal), brigand_marauder (Brigand),
// imperium_undead_overseer (Boss).
var expected = new HashSet<string>
{
"imperium_undead_thrall", "imperium_feral_canid",
"brigand_marauder", "imperium_undead_overseer",
};
foreach (var s in pop.Spawns)
Assert.Contains(s.Template.Id, expected);
}
[Fact]
public void Populate_BossRoom_GetsBossTemplate()
{
// Imperium medium layout requires a boss room. The boss-role room's
// encounter slots that declare Boss kind should resolve to the
// dungeon type's Boss template.
for (int i = 0; i < 5; i++)
{
var d = DungeonGenerator.Generate(0xB05UL + (ulong)i, i, PoiType.ImperiumRuin, _content);
var layout = _content.DungeonLayouts["imperium_medium"];
var pop = DungeonPopulator.Populate(d, layout, _content, levelBand: 2, worldSeed: 0xB05UL + (ulong)i);
// Find the boss room.
int bossRoomId = -1;
foreach (var r in d.Rooms)
if (r.Role == RoomRole.Boss) { bossRoomId = r.Id; break; }
Assert.NotEqual(-1, bossRoomId);
// The boss-room's Boss-kind spawn should be the overseer.
bool foundBoss = false;
foreach (var s in pop.Spawns)
if (s.RoomId == bossRoomId && s.Kind == "Boss")
{ foundBoss = true; Assert.Equal("imperium_undead_overseer", s.Template.Id); }
Assert.True(foundBoss, "Boss room should have a Boss-kind spawn");
}
}
[Fact]
public void Populate_ContainerSlots_HaveDropsAndTable()
{
var pop = Populate(0xC0FFEEUL, 1, PoiType.ImperiumRuin, levelBand: 2);
// Imperium pillar_room_cardinal + sarcophagus_chamber + boss_throne_room
// each have a container slot, so we should see at least one.
Assert.NotEmpty(pop.Containers);
foreach (var c in pop.Containers)
{
Assert.False(string.IsNullOrEmpty(c.TableId),
$"container in room {c.RoomId} has no table id");
}
}
[Fact]
public void Populate_AllContainerTableIds_ResolveToRealTables()
{
// Across multiple seeds and level bands, every populated container
// should reference a loot table that exists in the resolver. This
// catches band-mapping bugs (e.g. layout missing a t3 entry) and
// confirms the resolver→populator wiring stays coherent.
for (int band = 0; band <= 3; band++)
for (int i = 0; i < 5; i++)
{
ulong seed = 0x1007UL + (ulong)i * 1000UL;
var pop = Populate(seed, i, PoiType.ImperiumRuin, levelBand: band);
foreach (var c in pop.Containers)
{
Assert.True(_content.LootTables.ContainsKey(c.TableId),
$"populator emitted unknown loot table '{c.TableId}' (band={band}, room={c.RoomId})");
}
}
}
}
@@ -0,0 +1,76 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Loot;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — determinism tests for the dungeon loot generator. Same
/// (table, containerSeed) → byte-identical item drops, regardless of
/// process / clock / PRNG warm-up.
/// </summary>
public sealed class LootGeneratorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void RollContainer_SameSeed_ProducesIdenticalDrops()
{
const ulong seed = 0xABCDEF;
var a = LootGenerator.RollContainer("loot_dungeon_imperium_t2", seed, _content.LootTables, _content.Items);
var b = LootGenerator.RollContainer("loot_dungeon_imperium_t2", seed, _content.LootTables, _content.Items);
Assert.Equal(a.Length, b.Length);
for (int i = 0; i < a.Length; i++)
{
Assert.Equal(a[i].Def.Id, b[i].Def.Id);
Assert.Equal(a[i].Qty, b[i].Qty);
}
}
[Fact]
public void RollContainer_DifferentSeeds_DivergeAcrossManyRolls()
{
// Across 100 (seed, slotIdx) pairs, the *aggregate* drop count
// should differ between two different base seeds. (A single pair
// could collide; the population can't, with overwhelming probability.)
int aTotal = 0, bTotal = 0;
for (int i = 0; i < 100; i++)
{
ulong seedA = 0x10000UL ^ (ulong)i;
ulong seedB = 0x20000UL ^ (ulong)i;
aTotal += LootGenerator.RollContainer("loot_dungeon_imperium_t2", seedA, _content.LootTables, _content.Items).Length;
bTotal += LootGenerator.RollContainer("loot_dungeon_imperium_t2", seedB, _content.LootTables, _content.Items).Length;
}
Assert.NotEqual(aTotal, bTotal);
}
[Fact]
public void RollContainer_HonoursDungeonLayoutSeedConvention()
{
ulong dungeonLayoutSeed = 0xD06E07AUL ^ 7UL; // simulated — same shape as DungeonGenerator
var a = LootGenerator.RollContainer(
"loot_dungeon_imperium_t1", dungeonLayoutSeed, slotIdx: 0,
_content.LootTables, _content.Items);
var b = LootGenerator.RollContainer(
"loot_dungeon_imperium_t1", dungeonLayoutSeed ^ C.RNG_DUNGEON_LOOT ^ 0UL,
_content.LootTables, _content.Items);
// Both forms should produce identical results — the convenience
// overload XORs the same RNG_DUNGEON_LOOT + slotIdx the explicit
// overload's caller would.
Assert.Equal(a.Length, b.Length);
for (int i = 0; i < a.Length; i++)
{
Assert.Equal(a[i].Def.Id, b[i].Def.Id);
Assert.Equal(a[i].Qty, b[i].Qty);
}
}
[Fact]
public void RollContainer_UnknownTable_ReturnsEmpty()
{
var drops = LootGenerator.RollContainer("nonexistent_table", 1, _content.LootTables, _content.Items);
Assert.Empty(drops);
}
}
@@ -0,0 +1,75 @@
using Theriapolis.Core;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — schema integrity tests for the Phase 7 constants. These
/// guard against silent regressions in:
/// - <see cref="C.SAVE_SCHEMA_VERSION"/> (must == 8 at Phase 7 ship)
/// - The 4 new RNG sub-streams (must be unique vs every existing stream)
/// - Dungeon size bands (must be a coherent ladder)
/// - Movement-cost multipliers (must be ≥ 1.0; squeezing must dominate)
/// </summary>
public sealed class Phase7ConstantsTests
{
[Fact]
public void SaveSchemaVersion_IsEight()
{
Assert.Equal(8, C.SAVE_SCHEMA_VERSION);
}
[Fact]
public void DungeonRngSubStreams_AreDistinctFromAllExistingStreams()
{
// Collect every named ulong RNG sub-stream by reflection. Each
// must be unique — a collision means two independent streams share
// a seed, breaking the dice contract.
var fields = typeof(C).GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(ulong))
.ToArray();
var seen = new Dictionary<ulong, string>();
foreach (var f in fields)
{
ulong value = (ulong)f.GetRawConstantValue()!;
if (seen.TryGetValue(value, out var prior))
Assert.Fail($"RNG sub-stream collision: {f.Name} == {prior} ({value:X})");
seen[value] = f.Name;
}
// Belt-and-braces: assert the four Phase 7 streams exist.
Assert.Contains(C.RNG_DUNGEON_LAYOUT, seen.Keys);
Assert.Contains(C.RNG_ROOM_PICK, seen.Keys);
Assert.Contains(C.RNG_DUNGEON_POPULATE, seen.Keys);
Assert.Contains(C.RNG_DUNGEON_LOOT, seen.Keys);
}
[Fact]
public void DungeonSizeBands_FormCoherentLadder()
{
Assert.True(C.DUNGEON_SMALL_ROOMS_MIN <= C.DUNGEON_SMALL_ROOMS_MAX);
Assert.True(C.DUNGEON_MED_ROOMS_MIN <= C.DUNGEON_MED_ROOMS_MAX);
Assert.True(C.DUNGEON_LARGE_ROOMS_MIN <= C.DUNGEON_LARGE_ROOMS_MAX);
// Ladders don't overlap — a small dungeon's max < medium's min.
Assert.True(C.DUNGEON_SMALL_ROOMS_MAX < C.DUNGEON_MED_ROOMS_MIN);
Assert.True(C.DUNGEON_MED_ROOMS_MAX < C.DUNGEON_LARGE_ROOMS_MIN);
}
[Fact]
public void MovementCostMultipliers_AreOrdered()
{
Assert.True(C.MOVE_COST_MISMATCH_LIGHT >= 1.0f,
"Mismatch must never give a speed bonus.");
Assert.True(C.MOVE_COST_MISMATCH_LIGHT < C.MOVE_COST_MISMATCH_MED);
Assert.True(C.MOVE_COST_MISMATCH_MED < C.MOVE_COST_MISMATCH_HEAVY);
}
[Fact]
public void LockAndTrapDcs_AreOrdered()
{
Assert.True(C.LOCK_DC_TRIVIAL < C.LOCK_DC_EASY);
Assert.True(C.LOCK_DC_EASY < C.LOCK_DC_MEDIUM);
Assert.True(C.LOCK_DC_MEDIUM < C.LOCK_DC_HARD);
Assert.True(C.TRAP_DC_TRIVIAL < C.TRAP_DC_EASY);
Assert.True(C.TRAP_DC_EASY < C.TRAP_DC_MEDIUM);
}
}
@@ -0,0 +1,107 @@
using Theriapolis.Core.Data;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — content-load tests for the room-template + dungeon-layout
/// schema. These run on the actual <c>Content/Data/room_templates/</c>
/// + <c>Content/Data/dungeon_layouts/</c> directories so a broken
/// authoring edit fails the build.
/// </summary>
public sealed class RoomTemplateValidationTests
{
private static ContentLoader Loader() => new(TestHelpers.DataDirectory);
[Fact]
public void RoomTemplates_LoadAndValidate()
{
// M0 vertical-slice: 5 imperium + 3 mine + 2 cave = 10 templates.
// Test asserts ≥ 5 to allow content authoring growth without
// modifying this test on every drop.
var rooms = Loader().LoadRoomTemplates();
Assert.True(rooms.Length >= 10,
$"expected ≥10 room templates after Phase 7 M0 vertical slice, got {rooms.Length}");
// Every template must declare at least one role and be one of the
// five known dungeon types.
var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "imperium", "mine", "cult", "cave", "overgrown" };
foreach (var r in rooms)
{
Assert.True(validTypes.Contains(r.Type), $"room '{r.Id}' has invalid type '{r.Type}'");
Assert.NotEmpty(r.RolesEligible);
}
}
[Fact]
public void EveryRoomTemplate_HasGridMatchingFootprint()
{
var rooms = Loader().LoadRoomTemplates();
foreach (var r in rooms)
{
Assert.Equal(r.FootprintHTiles, r.Grid.Length);
for (int y = 0; y < r.Grid.Length; y++)
Assert.Equal(r.FootprintWTiles, r.Grid[y].Length);
}
}
[Fact]
public void EveryRoomTemplate_HasIntactPerimeter()
{
var rooms = Loader().LoadRoomTemplates();
foreach (var r in rooms)
{
int w = r.FootprintWTiles, h = r.FootprintHTiles;
for (int x = 0; x < w; x++)
{
Assert.True(IsPerimeterChar(r.Grid[0][x]),
$"room '{r.Id}' top perimeter ({x},0) is '{r.Grid[0][x]}'");
Assert.True(IsPerimeterChar(r.Grid[h - 1][x]),
$"room '{r.Id}' bottom perimeter ({x},{h - 1}) is '{r.Grid[h - 1][x]}'");
}
for (int y = 0; y < h; y++)
{
Assert.True(IsPerimeterChar(r.Grid[y][0]),
$"room '{r.Id}' left perimeter (0,{y}) is '{r.Grid[y][0]}'");
Assert.True(IsPerimeterChar(r.Grid[y][w - 1]),
$"room '{r.Id}' right perimeter ({w - 1},{y}) is '{r.Grid[y][w - 1]}'");
}
}
}
private static bool IsPerimeterChar(char c) => c == '#' || c == 'D' || c == 'S';
[Fact]
public void DungeonLayouts_LoadAndValidate()
{
var loader = Loader();
var rooms = loader.LoadRoomTemplates();
var loot = loader.LoadLootTables(loader.LoadItems());
var layouts = loader.LoadDungeonLayouts(rooms, loot);
// M0 vertical-slice: imperium_medium + mine_small = 2 layouts.
Assert.True(layouts.Length >= 2,
$"expected ≥2 dungeon layouts after Phase 7 M0, got {layouts.Length}");
// Every layout must declare a coherent room-count band.
foreach (var l in layouts)
{
Assert.True(l.RoomCountMin >= 1, $"layout '{l.Id}' room_count_min < 1");
Assert.True(l.RoomCountMax >= l.RoomCountMin, $"layout '{l.Id}' room_count_max < min");
}
}
[Fact]
public void EveryLayout_LootTableReferences_Resolve()
{
var loader = Loader();
var loot = loader.LoadLootTables(loader.LoadItems());
var layouts = loader.LoadDungeonLayouts(loader.LoadRoomTemplates(), loot);
var ids = new HashSet<string>(loot.Select(t => t.Id), StringComparer.OrdinalIgnoreCase);
foreach (var l in layouts)
foreach (var (band, table) in l.LootTablePerBand)
Assert.True(ids.Contains(table),
$"layout '{l.Id}' loot_table_per_band['{band}'] = '{table}' not in loot_tables.json");
}
}