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:
@@ -0,0 +1,75 @@
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Settlements;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — AnchorRegistry semantics.
|
||||
/// </summary>
|
||||
public sealed class AnchorRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Anchor_RegistersAndResolves()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterAnchor(NarrativeAnchor.Millhaven, settlementId: 42);
|
||||
Assert.Equal(42, r.ResolveAnchor("anchor:millhaven"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anchor_LookupIsCaseInsensitive()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterAnchor(NarrativeAnchor.Millhaven, settlementId: 42);
|
||||
Assert.Equal(42, r.ResolveAnchor("ANCHOR:MILLHAVEN"));
|
||||
Assert.Equal(42, r.ResolveAnchor("anchor:Millhaven"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredAnchor_ReturnsNull()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
Assert.Null(r.ResolveAnchor("anchor:doesnotexist"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedRole_RegistersAndResolves()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterRole("millhaven.innkeeper", npcId: 777);
|
||||
Assert.Equal(777, r.ResolveRole("role:millhaven.innkeeper"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericRoleTag_DoesNotRegister()
|
||||
{
|
||||
// A bare role tag without a "settlement.role" qualifier shouldn't
|
||||
// be globally addressable — there are many generic innkeepers.
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterRole("innkeeper", npcId: 5);
|
||||
Assert.Null(r.ResolveRole("role:innkeeper"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisterRole_RemovesEntry()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterRole("millhaven.innkeeper", npcId: 5);
|
||||
r.UnregisterRole("millhaven.innkeeper");
|
||||
Assert.Null(r.ResolveRole("role:millhaven.innkeeper"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_DropsAllEntries()
|
||||
{
|
||||
var r = new AnchorRegistry();
|
||||
r.RegisterAnchor(NarrativeAnchor.Millhaven, 1);
|
||||
r.RegisterRole("millhaven.innkeeper", 5);
|
||||
r.Clear();
|
||||
Assert.Null(r.ResolveAnchor("anchor:millhaven"));
|
||||
Assert.Null(r.ResolveRole("role:millhaven.innkeeper"));
|
||||
Assert.Empty(r.AllAnchors);
|
||||
Assert.Empty(r.AllRoles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Settlements;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — building stamp determinism + content shape tests.
|
||||
///
|
||||
/// The settlement-stamping path picks up content lazily on the first chunk
|
||||
/// that touches each settlement; identical seeds must produce identical
|
||||
/// building lists, and the stamped tile bytes must round-trip across two
|
||||
/// independent generations.
|
||||
/// </summary>
|
||||
public sealed class BuildingStampTests : IClassFixture<WorldCache>
|
||||
{
|
||||
private const ulong TestSeed = 0xCAFEBABEUL;
|
||||
private readonly WorldCache _cache;
|
||||
public BuildingStampTests(WorldCache c) => _cache = c;
|
||||
|
||||
private SettlementContent LoadContent()
|
||||
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Settlements;
|
||||
|
||||
[Fact]
|
||||
public void ContentLoader_LoadsBuildingsAndLayouts()
|
||||
{
|
||||
var content = LoadContent();
|
||||
Assert.True(content.Buildings.Count >= 6,
|
||||
$"expected ≥ 6 building templates, got {content.Buildings.Count}");
|
||||
Assert.True(content.PresetByAnchor.Count >= 1,
|
||||
"expected at least one preset settlement layout");
|
||||
Assert.True(content.ProceduralByTier.Count >= 4,
|
||||
"expected procedural layouts for Tier 2/3/4/5");
|
||||
// Sanity: every preset must reference real building templates.
|
||||
foreach (var p in content.PresetByAnchor.Values)
|
||||
foreach (var b in p.Buildings)
|
||||
Assert.True(content.Buildings.ContainsKey(b.Template),
|
||||
$"preset '{p.Id}' references unknown template '{b.Template}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamp_ProducesIdenticalBuildingsAcrossRuns()
|
||||
{
|
||||
var w1 = _cache.Get(TestSeed, variant: 0).World;
|
||||
var w2 = _cache.Get(TestSeed, variant: 1).World;
|
||||
var content = LoadContent();
|
||||
|
||||
var s1 = w1.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
|
||||
var s2 = w2.Settlements.First(s => s.Id == s1.Id);
|
||||
|
||||
SettlementStamper.EnsureBuildingsResolved(TestSeed, s1, content);
|
||||
SettlementStamper.EnsureBuildingsResolved(TestSeed, s2, content);
|
||||
|
||||
Assert.Equal(s1.Buildings.Count, s2.Buildings.Count);
|
||||
for (int i = 0; i < s1.Buildings.Count; i++)
|
||||
{
|
||||
Assert.Equal(s1.Buildings[i].TemplateId, s2.Buildings[i].TemplateId);
|
||||
Assert.Equal(s1.Buildings[i].MinX, s2.Buildings[i].MinX);
|
||||
Assert.Equal(s1.Buildings[i].MinY, s2.Buildings[i].MinY);
|
||||
Assert.Equal(s1.Buildings[i].MaxX, s2.Buildings[i].MaxX);
|
||||
Assert.Equal(s1.Buildings[i].MaxY, s2.Buildings[i].MaxY);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamp_ProducesIdenticalChunkHashWithSameContent()
|
||||
{
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
|
||||
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
|
||||
|
||||
var a = TacticalChunkGen.Generate(TestSeed, cc, w, content);
|
||||
// Re-resolve buildings from a fresh world to make sure the stamper
|
||||
// is idempotent (BuildingsResolved guard works).
|
||||
var w2 = _cache.Get(TestSeed, variant: 1).World;
|
||||
var b = TacticalChunkGen.Generate(TestSeed, cc, w2, content);
|
||||
Assert.Equal(a.Hash(), b.Hash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamp_ContentNullFallsBackToLegacyHash()
|
||||
{
|
||||
// Generating a chunk without content should match a chunk generated
|
||||
// with the no-content overload — i.e., the fallback path is the
|
||||
// Phase-4 placeholder behaviour, byte-for-byte.
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
|
||||
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
|
||||
|
||||
var a = TacticalChunkGen.Generate(TestSeed, cc, w);
|
||||
var b = TacticalChunkGen.Generate(TestSeed, cc, w, settlementContent: null);
|
||||
Assert.Equal(a.Hash(), b.Hash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampedSettlement_HasMoreBuildingTilesThanFallback()
|
||||
{
|
||||
// The whole point of M0 — content path stamps Floor tiles inside
|
||||
// building footprints, fallback only stamps Cobble. Floor count
|
||||
// diverges; this captures that.
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
|
||||
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
|
||||
|
||||
var withContent = TacticalChunkGen.Generate(TestSeed, cc, w, content);
|
||||
var w2 = _cache.Get(TestSeed, variant: 1).World;
|
||||
var without = TacticalChunkGen.Generate(TestSeed, cc, w2, settlementContent: null);
|
||||
|
||||
int floorWith = CountSurface(withContent, TacticalSurface.Floor);
|
||||
int floorWithout = CountSurface(without, TacticalSurface.Floor);
|
||||
Assert.True(floorWith > floorWithout,
|
||||
$"Content-aware stamp should produce floor tiles; got {floorWith} vs {floorWithout}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buildings_HaveDoorsAndDoorsAreWalkable()
|
||||
{
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3);
|
||||
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
|
||||
Assert.NotEmpty(s.Buildings);
|
||||
|
||||
// Render a chunk that overlaps each building and confirm the door
|
||||
// tile is walkable + carries the Doorway flag.
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
Assert.NotEmpty(b.Doors);
|
||||
foreach (var (dx, dy) in b.Doors)
|
||||
{
|
||||
var cc = new ChunkCoord(
|
||||
dx / C.TACTICAL_CHUNK_SIZE - (dx < 0 ? 1 : 0),
|
||||
dy / C.TACTICAL_CHUNK_SIZE - (dy < 0 ? 1 : 0));
|
||||
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content);
|
||||
int lx = dx - chunk.OriginX;
|
||||
int ly = dy - chunk.OriginY;
|
||||
Assert.InRange(lx, 0, C.TACTICAL_CHUNK_SIZE - 1);
|
||||
Assert.InRange(ly, 0, C.TACTICAL_CHUNK_SIZE - 1);
|
||||
ref var tile = ref chunk.Tiles[lx, ly];
|
||||
Assert.True(tile.IsWalkable, $"door at ({dx},{dy}) for building {b.TemplateId} should be walkable");
|
||||
Assert.True((tile.Flags & (byte)TacticalFlags.Doorway) != 0, "door tile must carry the Doorway flag");
|
||||
Assert.True((tile.Flags & (byte)TacticalFlags.Building) != 0, "door tile must carry the Building flag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MillhavenAnchor_GetsItsPresetLayout()
|
||||
{
|
||||
// We can't guarantee the Millhaven anchor exists at every test seed
|
||||
// (placement depends on world geometry). When it does, it should
|
||||
// resolve to the preset layout, not the procedural Tier-1 fallback.
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
var millhaven = w.Settlements.FirstOrDefault(
|
||||
s => s.Anchor is NarrativeAnchor.Millhaven);
|
||||
if (millhaven is null) return;
|
||||
|
||||
var layout = content.ResolveFor(millhaven);
|
||||
Assert.NotNull(layout);
|
||||
Assert.Equal("preset", layout!.Kind);
|
||||
Assert.Equal("Millhaven", layout.Anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProceduralLayouts_StampMultipleBuildings()
|
||||
{
|
||||
// Pick a non-anchor Tier 2 or 3 settlement and confirm the
|
||||
// procedural roller produced more than one building. Sanity that
|
||||
// the weighted picker doesn't collapse to zero.
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
var s = w.Settlements.FirstOrDefault(
|
||||
x => x.Anchor is null && !x.IsPoi && x.Tier is 2 or 3);
|
||||
if (s is null) return;
|
||||
|
||||
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
|
||||
Assert.True(s.Buildings.Count >= 2,
|
||||
$"procedural Tier-{s.Tier} settlement should stamp ≥ 2 buildings, got {s.Buildings.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResidentSpawns_AppearInChunkSpawnList()
|
||||
{
|
||||
var w = _cache.Get(TestSeed).World;
|
||||
var content = LoadContent();
|
||||
|
||||
// Find any settlement at Tier ≤ 3 whose layout has roles.
|
||||
var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3);
|
||||
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
|
||||
|
||||
// Sum role count across buildings.
|
||||
int expectedRoles = s.Buildings.Sum(b => b.Residents.Length);
|
||||
if (expectedRoles == 0) return;
|
||||
|
||||
// Generate every chunk overlapping any building and count Resident
|
||||
// spawn records emitted.
|
||||
int actual = 0;
|
||||
var seen = new HashSet<ChunkCoord>();
|
||||
foreach (var b in s.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);
|
||||
if (!seen.Add(cc)) continue;
|
||||
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content);
|
||||
foreach (var sp in chunk.Spawns)
|
||||
if (sp.Kind == SpawnKind.Resident) actual++;
|
||||
}
|
||||
}
|
||||
Assert.Equal(expectedRoles, actual);
|
||||
}
|
||||
|
||||
private static int CountSurface(TacticalChunk chunk, TacticalSurface surface)
|
||||
{
|
||||
int n = 0;
|
||||
for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
|
||||
for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
|
||||
if (chunk.Tiles[x, y].Surface == surface) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user