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);
}
}