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