using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Reputation; using Xunit; namespace Theriapolis.Tests.Reputation; /// /// Phase 6.5 M7 — betrayal cascade: tier mapping, faction propagation /// through the opposition matrix, permanent memory tag, sticky aggro /// flag on guard-style NPCs in the betrayed faction. /// public sealed class BetrayalCascadeTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Tier resolution ─────────────────────────────────────────────────── [Theory] [InlineData(0, 0)] // not a betrayal [InlineData(-5, 0)] // sub-minor → no faction cascade [InlineData(-10, -5)] // minor [InlineData(-24, -5)] // still minor [InlineData(-25, -15)] // moderate [InlineData(-49, -15)] // still moderate [InlineData(-50, -30)] // major [InlineData(-74, -30)] // still major [InlineData(-75, -50)] // critical [InlineData(-100, -50)] // floor public void ResolveFactionDelta_TiersByMagnitude(int magnitude, int expected) { Assert.Equal(expected, BetrayalCascade.ResolveFactionDelta(magnitude)); } // ── Apply: cascade outcomes ─────────────────────────────────────────── [Fact] public void Apply_NonBetrayalEvent_ReturnsEmpty() { var rep = new PlayerReputation(); var ev = MakeBetrayalEvent("test.role", magnitude: 0); // magnitude 0 = not a betrayal var result = BetrayalCascade.Apply(ev, rep, betrayedNpc: null, npcs: System.Linq.Enumerable.Empty(), factions: _content.Factions); Assert.True(result.IsEmpty); Assert.Empty(rep.Ledger.Entries); } [Fact] public void Apply_PositiveMagnitude_NoCascade() { var rep = new PlayerReputation(); var ev = MakeBetrayalEvent("test.role", magnitude: 10); // positive — not a betrayal var result = BetrayalCascade.Apply(ev, rep, betrayedNpc: null, npcs: System.Linq.Enumerable.Empty(), factions: _content.Factions); Assert.True(result.IsEmpty); } [Fact] public void Apply_WritesBetrayedMeMemoryFlag() { var rep = new PlayerReputation(); var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -25); BetrayalCascade.Apply(ev, rep, betrayedNpc: null, npcs: System.Linq.Enumerable.Empty(), factions: _content.Factions); Assert.Contains("betrayed_me", rep.PersonalFor("millhaven.asha").Memory); } [Fact] public void Apply_AppliesFactionDeltaFromBetrayedNpc() { var rep = new PlayerReputation(); var asha = MakeNpc(faction: "hybrid_underground", behavior: "resident"); var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -25); var result = BetrayalCascade.Apply(ev, rep, asha, npcs: System.Linq.Enumerable.Empty(), factions: _content.Factions); Assert.Equal("hybrid_underground", result.factionId); // Hybrid_underground: -15 (moderate tier). Assert.Equal(-15, rep.Factions.Get("hybrid_underground")); // Opposition cascade: hybrid_underground hates inheritors (-0.5), // so -15 with hybrid_underground → +7 with inheritors (negative × // negative). Sign check. int inheritors = rep.Factions.Get("inheritors"); Assert.True(inheritors > 0, $"expected positive inheritor delta from cascade, got {inheritors}"); } [Fact] public void Apply_LedgerRecordsCascadeAsFactionTaggedEvent() { var rep = new PlayerReputation(); var asha = MakeNpc(faction: "hybrid_underground", behavior: "resident"); var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -50); BetrayalCascade.Apply(ev, rep, asha, npcs: System.Linq.Enumerable.Empty(), factions: _content.Factions); Assert.Contains(rep.Ledger.Entries, e => e.Kind == RepEventKind.Betrayal && e.FactionId == "hybrid_underground" && e.RoleTag == "millhaven.asha"); } [Fact] public void Apply_NoFactionCascadeWhenNpcAndEventBothLackFaction() { var rep = new PlayerReputation(); var npc = MakeNpc(faction: "", behavior: "brigand"); var ev = MakeBetrayalEvent("test.role", magnitude: -25); var result = BetrayalCascade.Apply(ev, rep, npc, npcs: new[] { npc }, factions: _content.Factions); // Personal flag still set, but no faction propagation. Assert.Contains("betrayed_me", rep.PersonalFor("test.role").Memory); Assert.Equal("", result.factionId); Assert.Empty(result.factionDeltas); } // ── Apply: permanent aggro flip ─────────────────────────────────────── [Fact] public void Apply_FlipsPermanentAggroOnSameFactionGuards() { var rep = new PlayerReputation(); var betrayed = MakeNpc(faction: "covenant_enforcers", behavior: "patrol"); var otherGuard = MakeNpc(faction: "covenant_enforcers", behavior: "patrol"); var unrelated = MakeNpc(faction: "merchant_guilds", behavior: "patrol"); var ev = MakeBetrayalEvent("guard.captain", magnitude: -50); var result = BetrayalCascade.Apply(ev, rep, betrayed, npcs: new[] { betrayed, otherGuard, unrelated }, factions: _content.Factions); Assert.True(otherGuard.PermanentAggroAfterBetrayal); Assert.False(unrelated.PermanentAggroAfterBetrayal); // wrong faction Assert.True(result.permanentAggroFlipped >= 1); } [Fact] public void Apply_DoesNotFlipCivilianResidents() { var rep = new PlayerReputation(); var betrayed = MakeNpc(faction: "merchant_guilds", behavior: "resident"); var civilianTrader = MakeNpc(faction: "merchant_guilds", behavior: "resident"); var ev = MakeBetrayalEvent("guild.master", magnitude: -50); BetrayalCascade.Apply(ev, rep, betrayed, npcs: new[] { betrayed, civilianTrader }, factions: _content.Factions); // Civilians stay non-aggro even on betrayal — only guard-style behaviors flip. Assert.False(civilianTrader.PermanentAggroAfterBetrayal); } [Theory] [InlineData("brigand")] [InlineData("patrol")] [InlineData("poi_guard")] [InlineData("wild_animal")] public void Apply_FlipsAggroForCombatBehaviors(string behavior) { var rep = new PlayerReputation(); var betrayed = MakeNpc(faction: "inheritors", behavior: behavior); var same = MakeNpc(faction: "inheritors", behavior: behavior); var ev = MakeBetrayalEvent("inheritor.captain", magnitude: -50); BetrayalCascade.Apply(ev, rep, betrayed, npcs: new[] { betrayed, same }, factions: _content.Factions); Assert.True(same.PermanentAggroAfterBetrayal); } [Fact] public void Apply_DoesNotReFlipAlreadyAggroedNpc() { var rep = new PlayerReputation(); var betrayed = MakeNpc(faction: "inheritors", behavior: "patrol"); var preFlipped = MakeNpc(faction: "inheritors", behavior: "patrol"); preFlipped.PermanentAggroAfterBetrayal = true; var ev = MakeBetrayalEvent("inheritor.scout", magnitude: -25); var result = BetrayalCascade.Apply(ev, rep, betrayed, npcs: new[] { betrayed, preFlipped }, factions: _content.Factions); // betrayed gets flipped (it's the same-faction guard), but // preFlipped is already-aggro and shouldn't double-count. Assert.True(betrayed.PermanentAggroAfterBetrayal); Assert.True(preFlipped.PermanentAggroAfterBetrayal); // Only `betrayed` flipped fresh in this call. Assert.Equal(1, result.permanentAggroFlipped); } // ── FactionAggression integration ───────────────────────────────────── [Fact] public void FactionAggression_FlipsAllegiance_OnPermanentAggroFlag() { // Simulate: a friendly resident with the betrayal aggro flag set // (no need for a hostile faction standing). FactionAggression // should still flip them to Hostile. var npc = MakeNpc(faction: "hybrid_underground", behavior: "resident"); npc.Allegiance = Theriapolis.Core.Rules.Character.Allegiance.Friendly; npc.PermanentAggroAfterBetrayal = true; // FactionAggression.UpdateAllegiances takes an ActorManager + content // + world. Build minimal fixtures. var actors = new ActorManager(); actors.SpawnNpc(npc); // No PC needed for the early-return null check; pass a stub character. var pc = MakeStubPc(); // Empty WorldState — UpdateAllegiances handles missing settlements gracefully. var world = new Theriapolis.Core.World.WorldState(); var rep = new PlayerReputation(); int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, _content, world, 0xCAFEUL); Assert.True(flipped >= 1); Assert.Equal(Theriapolis.Core.Rules.Character.Allegiance.Hostile, npc.Allegiance); } // ── Helpers ─────────────────────────────────────────────────────────── private static RepEvent MakeBetrayalEvent(string roleTag, int magnitude) => new() { Kind = RepEventKind.Betrayal, RoleTag = roleTag, Magnitude = magnitude, Note = "test betrayal", TimestampSeconds = 1000, }; // Monotone counter so every NPC gets a positive Id — needed because // ActorManager.SpawnNpc clones (and resets initialization-time flags) // when Id ≤ 0. private static int _nextNpcId = 1000; private static NpcActor MakeNpc(string faction, string behavior) { // Use NpcTemplateDef so we can set an arbitrary Behavior id. var template = new NpcTemplateDef { Id = "test_" + behavior, Name = "Test NPC", Hp = 20, Behavior = behavior, Faction = faction, DefaultAllegiance = "neutral", }; return new NpcActor(template) { Id = ++_nextNpcId }; } private Theriapolis.Core.Rules.Character.Character MakeStubPc() { var b = new Theriapolis.Core.Rules.Character.CharacterBuilder { Clade = _content.Clades["canidae"], Species = _content.Species["wolf"], ClassDef = _content.Classes["fangsworn"], Background = _content.Backgrounds["pack_raised"], BaseAbilities = new Theriapolis.Core.Rules.Stats.AbilityScores(15, 14, 13, 12, 10, 8), }; int n = b.ClassDef.SkillsChoose; foreach (var raw in b.ClassDef.SkillOptions) { if (b.ChosenClassSkills.Count >= n) break; try { b.ChosenClassSkills.Add(Theriapolis.Core.Rules.Stats.SkillIdExtensions.FromJson(raw)); } catch { } } return b.Build(_content.Items); } }