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,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));
|
||||
}
|
||||
}
|
||||
@@ -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 1–8) 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user