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);
}
}