Files
TheriapolisV3/Theriapolis.Tests/Settlements/ResidentSpawnTests.cs
T

217 lines
9.2 KiB
C#
Raw Normal View History

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;
/// <summary>
/// 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.
/// </summary>
public sealed class ResidentSpawnTests : IClassFixture<WorldCache>
{
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);
}
}