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; /// /// 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. /// 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(); 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); } }