b451f83174
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>
215 lines
8.2 KiB
C#
215 lines
8.2 KiB
C#
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 };
|
||
}
|
||
}
|