using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Xunit; namespace Theriapolis.Tests.Entities; /// /// 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. /// 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 }; } }