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,62 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class ActorCharacterTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void SpawnPlayer_WithCharacter_AttachesIt()
{
var mgr = new ActorManager();
var character = MakeCharacter();
var p = mgr.SpawnPlayer(new Vec2(100, 200), character);
Assert.NotNull(p.Character);
Assert.Equal("Wolf-Folk", p.Character!.Species.Name);
Assert.Equal(Allegiance.Player, p.Allegiance);
}
[Fact]
public void SpawnPlayer_NoCharacter_LeavesItNull()
{
var mgr = new ActorManager();
var p = mgr.SpawnPlayer(new Vec2(100, 200));
Assert.Null(p.Character);
Assert.True(p.IsAlive); // null-character actors are considered alive
}
[Fact]
public void Actor_IsAlive_ReflectsCharacterHp()
{
var mgr = new ActorManager();
var character = MakeCharacter();
var p = mgr.SpawnPlayer(new Vec2(0, 0), character);
Assert.True(p.IsAlive);
character.CurrentHp = 0;
Assert.False(p.IsAlive);
character.Conditions.Add(Condition.Unconscious);
Assert.True(p.IsAlive); // unconscious counts as alive (death-save loop)
}
private Character MakeCharacter()
{
return 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, 12, 10, 8),
Name = "Test",
}
.ChooseSkill(SkillId.Athletics)
.ChooseSkill(SkillId.Intimidation)
.Build();
}
}
@@ -0,0 +1,58 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class NpcActorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Construct_AssignsHpAndAllegianceFromTemplate()
{
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var npc = new NpcActor(t);
Assert.Equal(t.Hp, npc.CurrentHp);
Assert.Equal(t.Hp, npc.MaxHp);
Assert.Equal(Theriapolis.Core.Rules.Character.Allegiance.Hostile, npc.Allegiance);
Assert.Equal("wild_animal", npc.BehaviorId);
Assert.True(npc.IsAlive);
}
[Fact]
public void IsAlive_FalseAtZeroHp()
{
var npc = new NpcActor(_content.Npcs.Templates.First(x => x.Id == "wolf"));
npc.CurrentHp = 0;
Assert.False(npc.IsAlive);
}
[Fact]
public void ActorManager_SpawnNpc_GivesUniqueIdsAndTracksSource()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(0, 0));
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var coord = new ChunkCoord(3, 4);
var npc1 = mgr.SpawnNpc(t, new Vec2(10, 10), coord, sourceSpawnIndex: 0);
var npc2 = mgr.SpawnNpc(t, new Vec2(11, 10), coord, sourceSpawnIndex: 1);
Assert.NotEqual(npc1.Id, npc2.Id);
Assert.Equal(2, mgr.Npcs.Count());
Assert.Same(npc1, mgr.FindNpcBySource(coord, 0));
Assert.Same(npc2, mgr.FindNpcBySource(coord, 1));
}
[Fact]
public void ActorManager_RemoveActor_CleansUp()
{
var mgr = new ActorManager();
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var npc = mgr.SpawnNpc(t, new Vec2(0, 0));
Assert.True(mgr.RemoveActor(npc.Id));
Assert.Empty(mgr.Npcs);
Assert.False(mgr.RemoveActor(npc.Id));
}
}
+214
View File
@@ -0,0 +1,214 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Xunit;
namespace Theriapolis.Tests.Entities;
/// <summary>
/// Phase 6.5 M6 — per-NPC scent profile data layer. Verifies the
/// ScentTag derivation: faction id → affiliation tag, runtime flags →
/// distress / activity tags, count truncation per the Scent Literacy
/// (top 1) vs Scent Mastery (top 3) tier.
/// </summary>
public sealed class ScentTagTests
{
// ── Faction → tag mapping ─────────────────────────────────────────────
[Theory]
[InlineData("maw", ScentTag.MawAffiliated)]
[InlineData("inheritors", ScentTag.InheritorAffiliated)]
[InlineData("thorn_council", ScentTag.ThornCouncilAffiliated)]
[InlineData("covenant_enforcers", ScentTag.CovenantEnforcerAffiliated)]
[InlineData("hybrid_underground", ScentTag.HybridUndergroundAffiliated)]
[InlineData("unsheathed", ScentTag.UnsheathedAffiliated)]
[InlineData("merchant_guilds", ScentTag.MerchantAffiliated)]
public void FromFactionId_MapsKnownFactions(string factionId, ScentTag expected)
{
Assert.Equal(expected, ScentTagExtensions.FromFactionId(factionId));
}
[Theory]
[InlineData("")]
[InlineData("not_a_real_faction")]
[InlineData("random_string")]
public void FromFactionId_ReturnsNoneForUnknown(string factionId)
{
Assert.Equal(ScentTag.None, ScentTagExtensions.FromFactionId(factionId));
}
[Fact]
public void FromFactionId_IsCaseInsensitive()
{
Assert.Equal(ScentTag.MawAffiliated, ScentTagExtensions.FromFactionId("MAW"));
Assert.Equal(ScentTag.InheritorAffiliated, ScentTagExtensions.FromFactionId("Inheritors"));
}
// ── ComputeScentTags: faction-derived ─────────────────────────────────
[Fact]
public void ComputeScentTags_LacroixSurfacesMawAffiliated()
{
// Lacroix scenario: faction=maw, full HP, no kills.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_MerchantSurfacesMerchantAffiliated()
{
var npc = MakeResidentNpc("canidae", "fox", factionId: "merchant_guilds");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MerchantAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_NoFactionEmpty()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Empty(tags);
}
// ── ComputeScentTags: runtime-derived ─────────────────────────────────
[Fact]
public void ComputeScentTags_RecentlyKilledSurfaces()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.HasRecentlyKilled = true;
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.RecentlyKilled, tags[0]);
}
[Fact]
public void ComputeScentTags_FrightenedAtLowHp()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
// Drop HP to 20% (below 25% threshold).
npc.CurrentHp = npc.MaxHp / 5;
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.Frightened, tags[0]);
}
[Fact]
public void ComputeScentTags_WoundedAtHalfHp()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
// Drop HP to ~40% (below 50% but above 25%).
npc.CurrentHp = (int)(npc.MaxHp * 0.4f);
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.Wounded, tags[0]);
}
[Fact]
public void ComputeScentTags_DeadEmitsNoFrightened()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.CurrentHp = 0;
var tags = npc.ComputeScentTags(maxCount: 5);
// Dead NPCs don't carry distress markers (they're past distress).
Assert.DoesNotContain(ScentTag.Frightened, tags);
Assert.DoesNotContain(ScentTag.Wounded, tags);
}
[Fact]
public void ComputeScentTags_ContrabandFlagSurfaces()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.CarriesContrabandFlag = true;
var tags = npc.ComputeScentTags(maxCount: 5);
Assert.Contains(ScentTag.CarriesContraband, tags);
}
// ── ComputeScentTags: priority + truncation ───────────────────────────
[Fact]
public void ComputeScentTags_FactionTagWinsAtMaxCount1()
{
// Faction (priority 18) leads runtime tags (priority 16+) when
// truncating to a single read.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
npc.CarriesContrabandFlag = true;
npc.CurrentHp = npc.MaxHp / 5; // also Frightened
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_MasteryReadsUpToThree()
{
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
npc.CurrentHp = npc.MaxHp / 5; // Frightened
npc.CarriesContrabandFlag = true;
var tags = npc.ComputeScentTags(maxCount: 3);
Assert.Equal(3, tags.Count);
// Order: faction (1), then runtime in declaration order.
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
Assert.Equal(ScentTag.RecentlyKilled, tags[1]);
Assert.Equal(ScentTag.Frightened, tags[2]);
}
[Fact]
public void ComputeScentTags_MaxCountZeroReturnsEmpty()
{
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
var tags = npc.ComputeScentTags(maxCount: 0);
Assert.Empty(tags);
}
[Fact]
public void ComputeScentTags_MaxCountFiveCapsAtAvailable()
{
// Only faction tag available; cap at 5 returns just the one.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
var tags = npc.ComputeScentTags(maxCount: 5);
Assert.Single(tags);
}
// ── DisplayName ───────────────────────────────────────────────────────
[Fact]
public void DisplayName_ProducesReadableText()
{
Assert.Equal("Maw-affiliated", ScentTag.MawAffiliated.DisplayName());
Assert.Equal("Recently killed", ScentTag.RecentlyKilled.DisplayName());
Assert.Equal("Inheritor-affiliated", ScentTag.InheritorAffiliated.DisplayName());
Assert.Equal("Frightened", ScentTag.Frightened.DisplayName());
}
[Fact]
public void IsNarrative_TrueForFactionTags_FalseForRuntimeTags()
{
Assert.True(ScentTag.MawAffiliated.IsNarrative());
Assert.True(ScentTag.MerchantAffiliated.IsNarrative());
Assert.False(ScentTag.RecentlyKilled.IsNarrative());
Assert.False(ScentTag.Frightened.IsNarrative());
Assert.False(ScentTag.None.IsNarrative());
}
// ── Helpers ───────────────────────────────────────────────────────────
private NpcActor MakeResidentNpc(string clade, string species, string factionId)
{
var resident = new ResidentTemplateDef
{
Id = "test_npc",
Name = "Test NPC",
Clade = clade,
Species = species,
Faction = factionId,
Hp = 20,
};
return new NpcActor(resident) { Id = 1 };
}
}
@@ -0,0 +1,54 @@
using Theriapolis.Core.Time;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class WorldClockTests
{
[Fact]
public void Advance_AccumulatesSeconds()
{
var c = new WorldClock();
c.Advance(120);
c.Advance(30);
Assert.Equal(150, c.InGameSeconds);
}
[Fact]
public void DayHourMinute_DerivedFromSeconds()
{
var c = new WorldClock();
c.Advance(WorldClock.SecondsPerDay * 3 + WorldClock.SecondsPerHour * 14 + WorldClock.SecondsPerMinute * 27);
Assert.Equal(3, c.Day);
Assert.Equal(14, c.Hour);
Assert.Equal(27, c.Minute);
}
[Fact]
public void Season_RotatesWithDays()
{
var c = new WorldClock();
// Advance past one full season (24 days).
c.Advance(WorldClock.SecondsPerDay * WorldClock.DaysPerSeason + 1);
Assert.Equal(Season.Summer, c.Season);
}
[Fact]
public void RoundTrip_RestoresState()
{
var c = new WorldClock();
c.Advance(98765);
var s = c.CaptureState();
var c2 = new WorldClock();
c2.RestoreState(s);
Assert.Equal(98765, c2.InGameSeconds);
}
[Fact]
public void Advance_RejectsNegative()
{
var c = new WorldClock();
Assert.Throws<ArgumentOutOfRangeException>(() => c.Advance(-5));
}
}
@@ -0,0 +1,55 @@
using Theriapolis.Core;
using Theriapolis.Core.Entities;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class WorldTravelPlannerTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public WorldTravelPlannerTests(WorldCache c) => _cache = c;
[Fact]
public void Plan_BetweenAdjacentSettlements_ReturnsConnectedPath()
{
var w = _cache.Get(TestSeed).World;
var inhabited = w.Settlements.Where(s => !s.IsPoi && s.Tier <= 3).Take(2).ToArray();
Assert.Equal(2, inhabited.Length);
var planner = new WorldTravelPlanner(w);
var path = planner.PlanTilePath(inhabited[0].TileX, inhabited[0].TileY,
inhabited[1].TileX, inhabited[1].TileY);
Assert.NotNull(path);
Assert.True(path!.Count >= 2);
// Endpoints match the request.
Assert.Equal((inhabited[0].TileX, inhabited[0].TileY), path[0]);
Assert.Equal((inhabited[1].TileX, inhabited[1].TileY), path[^1]);
// No teleporting — every adjacent pair is within Chebyshev 1.
for (int i = 1; i < path.Count; i++)
{
int dx = Math.Abs(path[i].X - path[i - 1].X);
int dy = Math.Abs(path[i].Y - path[i - 1].Y);
Assert.True(dx <= 1 && dy <= 1);
Assert.False(dx == 0 && dy == 0);
}
}
[Fact]
public void Plan_FromOcean_ReturnsNull()
{
var w = _cache.Get(TestSeed).World;
// Find an ocean tile and a land tile.
(int ox, int oy) = (-1, -1);
for (int y = 0; y < C.WORLD_HEIGHT_TILES && oy < 0; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES && oy < 0; x++)
if (w.TileAt(x, y).Biome == BiomeId.Ocean) { ox = x; oy = y; }
Assert.True(ox >= 0, "world should have at least one ocean tile");
var inhabited = w.Settlements.First(s => !s.IsPoi);
var planner = new WorldTravelPlanner(w);
var path = planner.PlanTilePath(ox, oy, inhabited.TileX, inhabited.TileY);
Assert.Null(path);
}
}