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>
134 lines
4.9 KiB
C#
134 lines
4.9 KiB
C#
using Theriapolis.Core.Data;
|
|
using Theriapolis.Core.Items;
|
|
using Theriapolis.Core.Rules.Character;
|
|
using Theriapolis.Core.Rules.Stats;
|
|
using Xunit;
|
|
|
|
namespace Theriapolis.Tests.Rules;
|
|
|
|
public sealed class DerivedStatsTests
|
|
{
|
|
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
|
|
|
[Fact]
|
|
public void ArmorClass_Unarmored_Is10PlusDexMod()
|
|
{
|
|
var c = MakeCharacter(dex: 14); // DEX 15 after wolf+canid mods (DEX +0 species, +0 clade) → final DEX 14, +2 mod
|
|
// Wolf-Folk: STR+1, no DEX mod from species; canid: CON+1, WIS+1.
|
|
// Base DEX 14 → final 14 → mod +2.
|
|
// Unarmored: 10 + 2 = 12.
|
|
Assert.Equal(12, DerivedStats.ArmorClass(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmorClass_LightArmor_AddsBaseAndDex()
|
|
{
|
|
var c = MakeCharacter(dex: 14);
|
|
var hide = c.Inventory.Add(_content.Items["hide_vest"]);
|
|
c.Inventory.TryEquip(hide, EquipSlot.Body, out _);
|
|
// Hide vest base 11, max DEX -1 (unlimited), DEX mod +2 → AC 13.
|
|
Assert.Equal(13, DerivedStats.ArmorClass(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmorClass_MediumArmor_CapsDex()
|
|
{
|
|
var c = MakeCharacter(dex: 18); // base 18 → final 18 → mod +4
|
|
var chain = c.Inventory.Add(_content.Items["chain_shirt"]);
|
|
c.Inventory.TryEquip(chain, EquipSlot.Body, out _);
|
|
// Chain shirt base 13, max DEX 2 → effective DEX bonus 2 → AC 15.
|
|
Assert.Equal(15, DerivedStats.ArmorClass(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmorClass_HeavyArmor_IgnoresDex()
|
|
{
|
|
var c = MakeCharacter(dex: 18);
|
|
var mail = c.Inventory.Add(_content.Items["chain_mail"]);
|
|
c.Inventory.TryEquip(mail, EquipSlot.Body, out _);
|
|
// Chain mail base 16, max DEX 0 → AC 16.
|
|
Assert.Equal(16, DerivedStats.ArmorClass(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmorClass_ShieldAdds()
|
|
{
|
|
var c = MakeCharacter(dex: 14);
|
|
var chain = c.Inventory.Add(_content.Items["chain_shirt"]);
|
|
c.Inventory.TryEquip(chain, EquipSlot.Body, out _);
|
|
var shield = c.Inventory.Add(_content.Items["standard_shield"]);
|
|
c.Inventory.TryEquip(shield, EquipSlot.OffHand, out _);
|
|
// 13 + min(2, 2) + 2 (shield) = 17.
|
|
Assert.Equal(17, DerivedStats.ArmorClass(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void Speed_BaseMatchesSpecies()
|
|
{
|
|
var c = MakeCharacter();
|
|
Assert.Equal(c.Species.BaseSpeedFt, DerivedStats.SpeedFt(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void CarryCapacity_StrTimes15TimesSizeMult()
|
|
{
|
|
var c = MakeCharacter(str: 14); // STR 15 after wolf+1
|
|
// Wolf-Folk is medium_large → mult = 1.0
|
|
Assert.Equal(15f * 15f * 1.0f, DerivedStats.CarryCapacityLb(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void Encumbrance_LightWhenWellUnderCap()
|
|
{
|
|
var c = MakeCharacter(str: 14);
|
|
c.Inventory.Add(_content.Items["fang_knife"]); // 0.5 lb
|
|
Assert.Equal(DerivedStats.EncumbranceBand.Light, DerivedStats.Encumbrance(c));
|
|
Assert.Equal(1.0f, DerivedStats.TacticalSpeedMult(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void Encumbrance_HeavyWhenOverSoftThreshold()
|
|
{
|
|
var c = MakeCharacter(str: 8); // STR 9 after wolf+1, cap = 9 * 15 = 135 lb
|
|
for (int i = 0; i < 60; i++) c.Inventory.Add(_content.Items["chain_mail"]); // 60 * 40 lb = 2400 lb
|
|
var enc = DerivedStats.Encumbrance(c);
|
|
Assert.Equal(DerivedStats.EncumbranceBand.Over, enc);
|
|
Assert.Equal(0.5f, DerivedStats.TacticalSpeedMult(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void Speed_DropsWhenEncumbered()
|
|
{
|
|
var c = MakeCharacter(str: 8);
|
|
int baseSpeed = DerivedStats.SpeedFt(c);
|
|
// Pile on chain mail to push past the hard threshold (1.5x cap).
|
|
for (int i = 0; i < 60; i++) c.Inventory.Add(_content.Items["chain_mail"]);
|
|
int encSpeed = DerivedStats.SpeedFt(c);
|
|
Assert.True(encSpeed < baseSpeed, $"Encumbered speed ({encSpeed}) should be less than base ({baseSpeed})");
|
|
}
|
|
|
|
[Fact]
|
|
public void Initiative_EqualsDexMod()
|
|
{
|
|
var c = MakeCharacter(dex: 18);
|
|
// DEX 18 → mod +4 (no DEX mod from wolf-folk or canid clade)
|
|
Assert.Equal(4, DerivedStats.Initiative(c));
|
|
}
|
|
|
|
private Character MakeCharacter(int str = 10, int dex = 10, int con = 10, int @int = 10, int wis = 10, int cha = 10)
|
|
{
|
|
var b = new CharacterBuilder
|
|
{
|
|
Clade = _content.Clades["canidae"],
|
|
Species = _content.Species["wolf"],
|
|
ClassDef = _content.Classes["fangsworn"],
|
|
Background = _content.Backgrounds["pack_raised"],
|
|
BaseAbilities = new AbilityScores(str, dex, con, @int, wis, cha),
|
|
Name = "Test",
|
|
};
|
|
b.ChosenClassSkills.Add(SkillId.Athletics);
|
|
b.ChosenClassSkills.Add(SkillId.Intimidation);
|
|
return b.Build(); // no starting kit — tests build inventory explicitly
|
|
}
|
|
}
|