using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Tactical; using Theriapolis.Core.World; using Theriapolis.Core.World.Settlements; using Xunit; namespace Theriapolis.Tests.Settlements; /// /// Phase 6 M1 — resident-instantiation correctness. /// /// Walks the full pipeline: chunk → SettlementStamper emits Resident spawn /// records → ResidentInstantiator resolves them → NpcActor lands inside /// the building with the right name, bias profile, and dialogue id. /// public sealed class ResidentSpawnTests : IClassFixture { private const ulong TestSeed = 0xCAFEBABEUL; private readonly WorldCache _cache; public ResidentSpawnTests(WorldCache c) => _cache = c; private ContentResolver Content() => new(new ContentLoader(TestHelpers.DataDirectory)); [Fact] public void NamedRoleTags_ResolveToHandAuthoredTemplates() { var content = Content(); // Direct lookup — these IDs are referenced by Millhaven's preset. Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.innkeeper")); Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.constable_fenn")); Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.grandmother_asha")); Assert.True(content.ResidentsByRoleTag.ContainsKey("thornfield.dr_venn")); var asha = content.ResidentsByRoleTag["millhaven.grandmother_asha"]; Assert.Equal("Grandmother Asha", asha.Name); Assert.Equal("canidae", asha.Clade); Assert.Equal("wolf", asha.Species); Assert.Equal("CANID_TRADITIONALIST", asha.BiasProfile); } [Fact] public void GenericRoleTags_FallBackToGenericTemplates() { var content = Content(); // Suffix-stripping: "anywhere.innkeeper" should resolve to the // generic_innkeeper template since no anywhere.* preset exists. var pick = ResidentInstantiator.ResolveTemplate( "anywhere.innkeeper", content, worldSeed: 1, settlementId: 1, buildingId: 0, spawnIndex: 0); Assert.NotNull(pick); Assert.False(pick!.Named); Assert.Equal("innkeeper", pick.RoleTag); } [Fact] public void ResidentInstantiator_PlacesNpcInsideBuilding() { var w = _cache.Get(TestSeed).World; var content = Content(); // Find a settlement that resolves to a layout (Millhaven if anchor // matches, else any Tier 2-3 procedural settlement). var settlement = w.Settlements.FirstOrDefault(s => s.Anchor is NarrativeAnchor.Millhaven) ?? w.Settlements.First(s => !s.IsPoi && s.Tier <= 3); SettlementStamper.EnsureBuildingsResolved(TestSeed, settlement, content.Settlements); Assert.NotEmpty(settlement.Buildings); // Pick the first building that has at least one resident slot. var building = settlement.Buildings.FirstOrDefault(b => b.Residents.Length > 0); Assert.NotNull(building); var slot = building!.Residents[0]; // Spawn it through the full path (chunk render → ResidentInstantiator). var actors = new ActorManager(); var registry = new AnchorRegistry(); registry.RegisterAllAnchors(w); var cc = new ChunkCoord( slot.SpawnX / C.TACTICAL_CHUNK_SIZE - (slot.SpawnX < 0 ? 1 : 0), slot.SpawnY / C.TACTICAL_CHUNK_SIZE - (slot.SpawnY < 0 ? 1 : 0)); var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements); // Find the spawn entry for this slot inside the chunk. int spawnIdx = -1; for (int i = 0; i < chunk.Spawns.Count; i++) { var s = chunk.Spawns[i]; int wx = chunk.OriginX + s.LocalX; int wy = chunk.OriginY + s.LocalY; if (s.Kind == SpawnKind.Resident && wx == slot.SpawnX && wy == slot.SpawnY) { spawnIdx = i; break; } } Assert.NotEqual(-1, spawnIdx); var npc = ResidentInstantiator.Spawn( TestSeed, chunk, spawnIdx, chunk.Spawns[spawnIdx], w, content, actors, registry); Assert.NotNull(npc); Assert.Equal(slot.SpawnX, (int)npc!.Position.X); Assert.Equal(slot.SpawnY, (int)npc.Position.Y); Assert.Equal(slot.RoleTag, npc.RoleTag); Assert.NotEmpty(npc.DisplayName); Assert.NotEmpty(npc.BiasProfileId); Assert.True(npc.IsAlive); } [Fact] public void NamedResident_RegistersInAnchorRegistry() { var w = _cache.Get(TestSeed).World; var content = Content(); var millhaven = w.Settlements.FirstOrDefault(s => s.Anchor is NarrativeAnchor.Millhaven); if (millhaven is null) return; // anchor placement varies — skip if absent SettlementStamper.EnsureBuildingsResolved(TestSeed, millhaven, content.Settlements); var actors = new ActorManager(); var registry = new AnchorRegistry(); registry.RegisterAllAnchors(w); // Stream every chunk overlapping each Millhaven building. foreach (var b in millhaven.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); var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements); for (int i = 0; i < chunk.Spawns.Count; i++) { var s = chunk.Spawns[i]; if (s.Kind != SpawnKind.Resident) continue; if (actors.FindNpcBySource(cc, i) is not null) continue; ResidentInstantiator.Spawn(TestSeed, chunk, i, s, w, content, actors, registry); } } } // Anchor entry exists. Assert.NotNull(registry.ResolveAnchor("anchor:millhaven")); // The named innkeeper role must be registered. var innkeeperId = registry.ResolveRole("role:millhaven.innkeeper"); Assert.NotNull(innkeeperId); var innkeeper = actors.Npcs.First(n => n.Id == innkeeperId.Value); Assert.Equal("Mara Threadwell", innkeeper.DisplayName); Assert.Equal("URBAN_PROGRESSIVE", innkeeper.BiasProfileId); } [Fact] public void GenericResidents_DoNotPolluteAnchorRegistry() { // Procedural Tier 2/3 settlements use generic role tags ("innkeeper") // — those should NOT register as roles (only anchor.role pairs do). var w = _cache.Get(TestSeed).World; var content = Content(); var settlement = w.Settlements.FirstOrDefault( s => s.Anchor is null && !s.IsPoi && s.Tier is 2 or 3); if (settlement is null) return; SettlementStamper.EnsureBuildingsResolved(TestSeed, settlement, content.Settlements); var actors = new ActorManager(); var registry = new AnchorRegistry(); foreach (var b in settlement.Buildings) foreach (var r in b.Residents) { int cx = (int)Math.Floor(r.SpawnX / (double)C.TACTICAL_CHUNK_SIZE); int cy = (int)Math.Floor(r.SpawnY / (double)C.TACTICAL_CHUNK_SIZE); var cc = new ChunkCoord(cx, cy); var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements); for (int i = 0; i < chunk.Spawns.Count; i++) { var s = chunk.Spawns[i]; if (s.Kind != SpawnKind.Resident) continue; int wx = chunk.OriginX + s.LocalX; int wy = chunk.OriginY + s.LocalY; if (wx != r.SpawnX || wy != r.SpawnY) continue; if (actors.FindNpcBySource(cc, i) is not null) continue; ResidentInstantiator.Spawn(TestSeed, chunk, i, s, w, content, actors, registry); } } // No role:* entries should exist for a generic-only settlement. foreach (var (id, _) in registry.AllRoles) Assert.DoesNotContain(".", id[..(id.IndexOf(':'))]); // sanity: prefix is "role:" Assert.True(registry.AllRoles.Count == 0, $"generic settlement should produce no named role registrations, got {registry.AllRoles.Count}"); } [Fact] public void ResidentInstantiator_IsDeterministic() { var content = Content(); // Generic role with multiple matching templates picks the same one // for the same seed/chunk/slot every time. var first = ResidentInstantiator.ResolveTemplate("village.shopkeeper", content, worldSeed: 0xCAFEBABEUL, settlementId: 5, buildingId: 2, spawnIndex: 0); var second = ResidentInstantiator.ResolveTemplate("village.shopkeeper", content, worldSeed: 0xCAFEBABEUL, settlementId: 5, buildingId: 2, spawnIndex: 0); Assert.NotNull(first); Assert.Equal(first!.Id, second!.Id); } }