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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -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);
}
}
@@ -0,0 +1,171 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — disposition formula correctness.
///
/// The blend is:
/// Total = CladeBias + SizeDifferential + FactionModifier + Personal
/// each layer independently testable. We synthesise small fixtures so the
/// tests don't depend on actual content.json values.
/// </summary>
public sealed class EffectiveDispositionTests
{
private static ContentResolver LoadContent()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
private static Character WolfPc(ContentResolver content)
{
var clade = content.Clades["canidae"];
var species = content.Species["wolf"];
var classD = content.Classes["fangsworn"];
var bg = content.Backgrounds["pack_raised"];
var b = new CharacterBuilder()
.WithClade(clade).WithSpecies(species)
.WithClass(classD).WithBackground(bg)
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
// Pick the right number of class skills.
int needed = classD.SkillsChoose;
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < needed; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { /* unknown skill name in content — skip */ }
}
return b.Build();
}
[Fact]
public void DispositionLabel_BoundariesMatchThresholds()
{
Assert.Equal(DispositionLabel.Champion, DispositionLabels.For( 80));
Assert.Equal(DispositionLabel.Allied, DispositionLabels.For( 60));
Assert.Equal(DispositionLabel.Friendly, DispositionLabels.For( 30));
Assert.Equal(DispositionLabel.Favorable, DispositionLabels.For( 10));
Assert.Equal(DispositionLabel.Neutral, DispositionLabels.For( 0));
Assert.Equal(DispositionLabel.Unfriendly, DispositionLabels.For(-10));
Assert.Equal(DispositionLabel.Antagonistic,DispositionLabels.For(-30));
Assert.Equal(DispositionLabel.Hostile, DispositionLabels.For(-60));
Assert.Equal(DispositionLabel.Nemesis, DispositionLabels.For(-90));
}
[Fact]
public void Breakdown_Sums_AllLayers()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// A wolf-folk PC vs a CANID_TRADITIONALIST resident: clade bias
// for canidae is +15, no size differential (Wolf-Folk = MediumLarge,
// generic_innkeeper is rabbit = Small → diff = +2 ⇒ -8 mod). Personal
// and faction = 0. So effective = 15 + (-8) = 7 → Favorable.
var template = content.Residents["generic_innkeeper"]; // Leporidae rabbit, URBAN_PROGRESSIVE
var npc = new NpcActor(template) { Id = 1, RoleTag = "innkeeper" };
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.Equal(br.CladeBias + br.SizeDifferential + br.FactionModifier + br.Personal, br.Total);
Assert.Equal(DispositionLabels.For(br.Total), br.Label);
}
[Fact]
public void Breakdown_HostileBiasProfile_ProducesNegativeTotal()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// Find a resident with a profile that's actively hostile to canidae
// (THORN_COUNCIL_HARDLINER → canidae -25).
var hostileTemplate = new ResidentTemplateDef
{
Id = "test_hardliner",
RoleTag = "test.hardliner",
Named = true,
Name = "Test Hardliner",
Clade = "cervidae",
Species = "elk",
BiasProfile = "THORN_COUNCIL_HARDLINER",
};
var npc = new NpcActor(hostileTemplate) { Id = 2, RoleTag = "test.hardliner" };
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total < 0,
$"Wolf-folk vs Thorn Council Hardliner should be negative; got {br.Total}");
}
[Fact]
public void Personal_Disposition_OverridesNeutralBaseline()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
var template = content.Residents["generic_innkeeper"];
var npc = new NpcActor(template) { Id = 3, RoleTag = "village.innkeeper" };
// Apply a +30 personal event. Effective should rise by ~30.
var before = EffectiveDisposition.For(npc, pc, rep, content);
rep.PersonalFor(npc.RoleTag).Apply(new RepEvent
{
Kind = RepEventKind.Aid, RoleTag = npc.RoleTag, Magnitude = 30,
});
var after = EffectiveDisposition.For(npc, pc, rep, content);
Assert.Equal(30, after - before);
}
[Fact]
public void Faction_Standing_ContributesToHalfWeight()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// Use a constable template (faction = covenant_enforcers).
var template = content.Residents["generic_constable"];
var npc = new NpcActor(template) { Id = 4, RoleTag = "village.constable" };
var before = EffectiveDisposition.For(npc, pc, rep, content);
rep.Factions.Set("covenant_enforcers", 40);
var after = EffectiveDisposition.For(npc, pc, rep, content);
// Direct faction affiliation contributes 0.5×; the bias profile
// (Covenant Faithful) layers an additional 0.25× × affinity/100
// for matching factions. For COVENANT_FAITHFUL with
// covenant_enforcers affinity = +25, the layered weight is
// 40 × 25/100 × 0.25 = 2.5 → rounds to 3 on top of the 20 from
// direct affiliation. So the delta lands in the 20..23 range.
int delta = after - before;
Assert.InRange(delta, 20, 23);
}
[Fact]
public void Score_IsClampedToRepRange()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
var template = content.Residents["generic_innkeeper"];
var npc = new NpcActor(template) { Id = 5, RoleTag = "any.innkeeper" };
// Stack +200 personal points; clamp should keep it at +100.
rep.PersonalFor(npc.RoleTag).Score = 250; // bypass Apply to force a value past clamp
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total <= C.REP_MAX);
rep.PersonalFor(npc.RoleTag).Score = -250;
br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total >= C.REP_MIN);
}
}
@@ -0,0 +1,130 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M5 — patrol/guard NPC allegiance flips when local disposition
/// drops to HOSTILE.
/// </summary>
public sealed class FactionAggressionTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public FactionAggressionTests(WorldCache c) => _cache = c;
private static Character WolfPc(ContentResolver content)
{
var b = new CharacterBuilder()
.WithClade(content.Clades["canidae"])
.WithSpecies(content.Species["wolf"])
.WithClass(content.Classes["fangsworn"])
.WithBackground(content.Backgrounds["pack_raised"])
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
var classD = content.Classes["fangsworn"];
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < classD.SkillsChoose; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { }
}
return b.Build();
}
[Fact]
public void EnforcerPatrol_FlipsToHostile_WhenStandingTanks()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
var s = world.Settlements.First();
// Spawn a militia patroller manually using the npc template so it
// carries the covenant_enforcers faction id.
var template = content.Npcs.Templates.First(t => t.Id == "militia_patrol");
var npc = actors.SpawnNpc(template, new Theriapolis.Core.Util.Vec2(s.TileX * C.WORLD_TILE_PIXELS, s.TileY * C.WORLD_TILE_PIXELS));
// Give them a home settlement so propagation has somewhere to land.
// (The init-only HomeSettlementId requires reconstruction; instead
// we re-spawn through the (NpcActor pre) path with the field set.)
actors.RemoveActor(npc.Id);
var pre = new NpcActor(template)
{
Id = -1,
Position = new Theriapolis.Core.Util.Vec2(s.TileX * C.WORLD_TILE_PIXELS, s.TileY * C.WORLD_TILE_PIXELS),
HomeSettlementId = s.Id,
};
var live = actors.SpawnNpc(pre);
Assert.Equal(Allegiance.Neutral, live.Allegiance);
Assert.Equal("covenant_enforcers", live.FactionId);
// Tank the player's Enforcer standing with a single big crime.
rep.Submit(new RepEvent
{
Kind = RepEventKind.Crime,
FactionId = "covenant_enforcers",
Magnitude = -80,
OriginTileX = s.TileX, OriginTileY = s.TileY,
}, content.Factions);
int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.True(flipped >= 1, $"expected ≥1 flip; got {flipped}");
Assert.Equal(Allegiance.Hostile, live.Allegiance);
}
[Fact]
public void Resident_WithoutFaction_DoesNotFlip()
{
// A friendly non-faction resident (innkeeper without faction) should
// never flip even with player infamy.
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
var template = content.Residents["generic_innkeeper"]; // no faction
var pre = new NpcActor(template)
{
Id = -1,
Position = new Theriapolis.Core.Util.Vec2(0, 0),
};
var live = actors.SpawnNpc(pre);
rep.Factions.Set("covenant_enforcers", -100);
FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.NotEqual(Allegiance.Hostile, live.Allegiance);
}
[Fact]
public void HostileNpc_StaysHostile_AndDoesNotChangeAgain()
{
// Sanity: a hostile NPC isn't "promoted" further (no "extra hostile").
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
// brigand_footpad has default_allegiance = hostile and no faction.
var template = content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var live = actors.SpawnNpc(template, new Theriapolis.Core.Util.Vec2(0, 0));
Assert.Equal(Allegiance.Hostile, live.Allegiance);
int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.Equal(0, flipped);
Assert.Equal(Allegiance.Hostile, live.Allegiance);
}
}
@@ -0,0 +1,110 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — opposition matrix application.
///
/// The plan §I-2 spells out the exact multipliers; this suite verifies
/// the cascade fires in both directions (gains and losses) and stays
/// inside the clamp range.
/// </summary>
public sealed class FactionOppositionTests
{
private static IReadOnlyDictionary<string, FactionDef> LoadFactions()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
[Fact]
public void GainWithInheritors_CascadesIntoOppositionLosses()
{
var factions = LoadFactions();
var standing = new FactionStanding();
var applied = standing.Apply("inheritors", 10, factions);
// Per the doc: +10 with Inheritors should yield -5 Enforcers,
// -2 Thorn Council, -3 Hybrid Underground, -3 Unsheathed.
Assert.Equal( 10, standing.Get("inheritors"));
Assert.Equal( -5, standing.Get("covenant_enforcers"));
Assert.Equal( -2, standing.Get("thorn_council"));
Assert.Equal( -3, standing.Get("hybrid_underground"));
Assert.Equal( -3, standing.Get("unsheathed"));
Assert.Equal( 0, standing.Get("merchant_guilds")); // multiplier 0
// The applied list reports every actually-changed faction.
Assert.Contains(applied, t => t.FactionId == "inheritors" && t.Delta == 10);
Assert.Contains(applied, t => t.FactionId == "covenant_enforcers" && t.Delta == -5);
}
[Fact]
public void LossWithInheritors_CascadesIntoOppositionGains()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Apply("inheritors", -20, factions);
// -20 × -0.5 = +10 with Enforcers.
Assert.Equal(-20, standing.Get("inheritors"));
Assert.Equal( 10, standing.Get("covenant_enforcers"));
Assert.Equal( 4, standing.Get("thorn_council"));
Assert.Equal( 6, standing.Get("hybrid_underground"));
Assert.Equal( 6, standing.Get("unsheathed"));
}
[Fact]
public void Standing_IsClampedToRepRange()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Apply("inheritors", 200, factions);
Assert.Equal(C.REP_MAX, standing.Get("inheritors"));
// Cascaded -100 is below the floor — verify clamping.
Assert.Equal(C.REP_MIN, standing.Get("covenant_enforcers"));
}
[Fact]
public void ZeroDelta_DoesNothing()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Set("inheritors", 25);
standing.Apply("inheritors", 0, factions);
Assert.Equal(25, standing.Get("inheritors"));
Assert.Equal( 0, standing.Get("covenant_enforcers"));
}
[Fact]
public void UnknownFaction_NoCascade()
{
var factions = LoadFactions();
var standing = new FactionStanding();
// No throw; standing accumulates against the unknown id.
standing.Apply("not_a_real_faction", 10, factions);
Assert.Equal(10, standing.Get("not_a_real_faction"));
Assert.Equal( 0, standing.Get("covenant_enforcers"));
}
[Fact]
public void SubmitEvent_AppliesFactionAndPersonal()
{
var factions = LoadFactions();
var rep = new PlayerReputation();
rep.Submit(new RepEvent
{
Kind = RepEventKind.Quest,
FactionId = "inheritors",
RoleTag = "test.someone",
Magnitude = 10,
Note = "test event",
}, factions);
Assert.Equal(10, rep.Factions.Get("inheritors"));
Assert.Equal(-5, rep.Factions.Get("covenant_enforcers"));
Assert.Equal(10, rep.PersonalFor("test.someone").Score);
Assert.Single(rep.Ledger.Entries);
}
}
@@ -0,0 +1,195 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M5 — propagation correctness: distance-band decay, opposition
/// cascade, frontier coin-flip determinism, NEMESIS/CHAMPION bypass.
/// </summary>
public sealed class RepPropagationTests
{
private static IReadOnlyDictionary<string, FactionDef> Factions()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
private static Settlement Sett(int id, int x, int y) => new()
{
Id = id,
Name = $"S{id}",
Tier = 3,
TileX = x,
TileY = y,
};
[Fact]
public void BandFor_MapsTilesToBands()
{
Assert.Equal(RepPropagation.DistanceBand.Origin, RepPropagation.BandFor(0));
Assert.Equal(RepPropagation.DistanceBand.Adjacent, RepPropagation.BandFor(1));
Assert.Equal(RepPropagation.DistanceBand.Adjacent, RepPropagation.BandFor(C.REP_ADJACENT_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Regional, RepPropagation.BandFor(C.REP_ADJACENT_DIST_TILES + 1));
Assert.Equal(RepPropagation.DistanceBand.Regional, RepPropagation.BandFor(C.REP_REGIONAL_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Continental, RepPropagation.BandFor(C.REP_REGIONAL_DIST_TILES + 1));
Assert.Equal(RepPropagation.DistanceBand.Continental, RepPropagation.BandFor(C.REP_CONTINENTAL_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Frontier, RepPropagation.BandFor(C.REP_CONTINENTAL_DIST_TILES + 1));
}
[Fact]
public void DecayPctFor_HasMonotonicallyDecreasingValues()
{
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Origin) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Adjacent));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Adjacent) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Regional));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Regional) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Continental));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Continental) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Frontier));
}
[Fact]
public void LocalStanding_AtOrigin_FullMagnitude()
{
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 100, OriginTileY = 100,
});
var s = Sett(1, 100, 100);
Assert.Equal(20, RepPropagation.LocalStandingFor("inheritors", s, 0xCAFEUL, ledger, Factions()));
}
[Fact]
public void LocalStanding_DecaysWithDistance()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 50, // (just under the bypass threshold)
OriginTileX = 100, OriginTileY = 100,
});
// Adjacent: 80% of 50 = 40
// Wait — 50 is exactly at threshold; let's use 49 to test decay.
ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 49,
OriginTileX = 100, OriginTileY = 100,
});
int origin = RepPropagation.LocalStandingFor("inheritors", Sett(1, 100, 100), 0xCAFEUL, ledger, f);
int adjacent = RepPropagation.LocalStandingFor("inheritors", Sett(2, 110, 100), 0xCAFEUL, ledger, f); // 10 tiles
int regional = RepPropagation.LocalStandingFor("inheritors", Sett(3, 150, 100), 0xCAFEUL, ledger, f); // 50 tiles
int continental= RepPropagation.LocalStandingFor("inheritors", Sett(4, 250, 100), 0xCAFEUL, ledger, f); // 150 tiles
Assert.Equal(49, origin);
Assert.Equal((int)System.Math.Round(49 * 0.80f), adjacent);
Assert.Equal((int)System.Math.Round(49 * 0.60f), regional);
Assert.Equal((int)System.Math.Round(49 * 0.40f), continental);
}
[Fact]
public void Cascade_AppliesOppositionMatrix()
{
// Player gains +20 with Inheritors at (100, 100). The Enforcers
// hate this (mult -0.5) → -10 should cascade to Enforcer standing
// even at origin.
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 100, OriginTileY = 100,
});
int enforcerLocal = RepPropagation.LocalStandingFor("covenant_enforcers",
Sett(1, 100, 100), 0xCAFEUL, ledger, f);
Assert.Equal(-10, enforcerLocal);
}
[Fact]
public void ExtremeMagnitude_BypassesDecay()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 60, // ≥ REP_EXTREME_BYPASS_MAGNITUDE
OriginTileX = 0, OriginTileY = 0,
});
// Settlement on the frontier (>200 tiles away).
int far = RepPropagation.LocalStandingFor("inheritors",
Sett(99, 250, 250), 0xCAFEUL, ledger, f);
Assert.Equal(60, far);
}
[Fact]
public void FrontierDelivered_IsDeterministic()
{
// Same seed, same event id, same settlement id → same answer.
bool a = RepPropagation.FrontierDelivered(0xCAFEUL, 1, 5);
bool b = RepPropagation.FrontierDelivered(0xCAFEUL, 1, 5);
Assert.Equal(a, b);
// Vary one input → may differ; just confirm we don't always hit one branch.
int trues = 0, falses = 0;
for (int i = 1; i <= 100; i++)
{
if (RepPropagation.FrontierDelivered(0xCAFEUL, i, 5)) trues++;
else falses++;
}
Assert.True(trues > 20 && falses > 20,
$"Expected roughly 50/50 distribution; got trues={trues}, falses={falses}");
}
[Fact]
public void FrontierEvent_OnlyAppliesWhenDelivered()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
SequenceId = 0, // assigned by Append → 1
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 0, OriginTileY = 0,
});
// Frontier settlement.
var s = Sett(1, 250, 250);
int got = RepPropagation.LocalStandingFor("inheritors", s, 0xCAFEUL, ledger, f);
// Either 0 (not delivered) or +4 (20 × 20%).
Assert.True(got == 0 || got == (int)System.Math.Round(20 * 0.20f),
$"frontier delivery should be 0 or {System.Math.Round(20 * 0.20f)}, got {got}");
}
[Fact]
public void ExplainLocalStanding_ReturnsRecentEvents()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors", Magnitude = 10, Note = "first",
OriginTileX = 100, OriginTileY = 100,
});
ledger.Append(new RepEvent
{
FactionId = "inheritors", Magnitude = -5, Note = "second",
OriginTileX = 100, OriginTileY = 100,
});
var s = Sett(1, 100, 100);
var explained = RepPropagation.ExplainLocalStanding("inheritors", s, 0xCAFEUL, ledger, f, max: 8).ToList();
Assert.Equal(2, explained.Count);
// Most-recent-first.
Assert.Equal("second", explained[0].Event.Note);
Assert.Equal("first", explained[1].Event.Note);
}
}
@@ -0,0 +1,105 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — round-trip <see cref="PlayerReputation"/> through the
/// codec. Every field must survive Capture → Restore identically. Plus
/// the SaveCodec serialization path itself: write a SaveBody with rep
/// data, parse it back, compare.
/// </summary>
public sealed class ReputationRoundTripTests
{
[Fact]
public void CaptureRestore_FactionStandings_RoundTrips()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var rep = new PlayerReputation();
rep.Factions.Apply("inheritors", 30, factions);
rep.Factions.Apply("merchant_guilds", 50, factions);
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.Equal(rep.Factions.Get("inheritors"), restored.Factions.Get("inheritors"));
Assert.Equal(rep.Factions.Get("covenant_enforcers"), restored.Factions.Get("covenant_enforcers"));
Assert.Equal(rep.Factions.Get("merchant_guilds"), restored.Factions.Get("merchant_guilds"));
}
[Fact]
public void CaptureRestore_PersonalDispositions_RoundTrip()
{
var rep = new PlayerReputation();
var pd = rep.PersonalFor("millhaven.innkeeper");
pd.Score = 25;
pd.Trust = TrustLevel.Familiar;
pd.Betrayed = false;
pd.Memory.Add("saved-her-kit");
pd.Memory.Add("paid-for-the-window");
pd.Log.Add(new RepEvent { Kind = RepEventKind.Aid, RoleTag = "millhaven.innkeeper",
Magnitude = 10, Note = "first" });
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.True(restored.Personal.ContainsKey("millhaven.innkeeper"));
var rPd = restored.Personal["millhaven.innkeeper"];
Assert.Equal(25, rPd.Score);
Assert.Equal(TrustLevel.Familiar, rPd.Trust);
Assert.Contains("saved-her-kit", rPd.Memory);
Assert.Contains("paid-for-the-window", rPd.Memory);
Assert.Single(rPd.Log);
Assert.Equal("first", rPd.Log[0].Note);
}
[Fact]
public void CaptureRestore_Ledger_RoundTrips()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var rep = new PlayerReputation();
rep.Submit(new RepEvent { Kind = RepEventKind.Quest, FactionId = "inheritors", Magnitude = 10, Note = "a" }, factions);
rep.Submit(new RepEvent { Kind = RepEventKind.Betrayal, FactionId = "thorn_council", Magnitude = -25, Note = "b" }, factions);
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.Equal(rep.Ledger.Count, restored.Ledger.Count);
Assert.Equal(rep.Ledger.Entries[0].Note, restored.Ledger.Entries[0].Note);
Assert.Equal(rep.Ledger.Entries[1].Kind, restored.Ledger.Entries[1].Kind);
}
[Fact]
public void SaveCodec_RoundTripsReputationState()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var body = new SaveBody();
body.Player.Name = "Tester";
body.Player.PositionX = 100;
body.Player.PositionY = 200;
// Populate reputation state.
var rep = new PlayerReputation();
rep.Submit(new RepEvent { Kind = RepEventKind.Quest, FactionId = "inheritors", Magnitude = 10 }, factions);
var pd = rep.PersonalFor("millhaven.constable_fenn");
pd.Score = 7;
pd.Memory.Add("met-at-the-magistrate");
body.ReputationState = ReputationCodec.Capture(rep);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xCAFE" };
var bytes = SaveCodec.Serialize(header, body);
var (h2, b2) = SaveCodec.Deserialize(bytes);
Assert.Equal(C.SAVE_SCHEMA_VERSION, h2.Version);
Assert.Equal( 10, b2.ReputationState.FactionStandings["inheritors"]);
Assert.Equal( -5, b2.ReputationState.FactionStandings["covenant_enforcers"]);
Assert.Single(b2.ReputationState.Personal);
Assert.Equal("millhaven.constable_fenn", b2.ReputationState.Personal[0].RoleTag);
Assert.Contains("met-at-the-magistrate", b2.ReputationState.Personal[0].MemoryTags);
Assert.Single(b2.ReputationState.Ledger);
}
}