Files
TheriapolisV3/Theriapolis.Tests/Reputation/EffectiveDispositionTests.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

172 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}