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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user