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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user