271 lines
11 KiB
C#
271 lines
11 KiB
C#
|
|
using Theriapolis.Core;
|
|||
|
|
using Theriapolis.Core.Data;
|
|||
|
|
using Theriapolis.Core.Entities;
|
|||
|
|
using Theriapolis.Core.Rules.Reputation;
|
|||
|
|
using Xunit;
|
|||
|
|
|
|||
|
|
namespace Theriapolis.Tests.Reputation;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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.
|
|||
|
|
/// </summary>
|
|||
|
|
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<NpcActor>(), 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<NpcActor>(), 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<NpcActor>(), 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<NpcActor>(), 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<NpcActor>(), 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);
|
|||
|
|
}
|
|||
|
|
}
|