Files
TheriapolisV3/Theriapolis.Tests/Reputation/BetrayalCascadeTests.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
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>
2026-04-30 20:40:51 -07:00

271 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}