b451f83174
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>
172 lines
7.0 KiB
C#
172 lines
7.0 KiB
C#
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);
|
||
}
|
||
}
|