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,95 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
public sealed class AbilityScoreTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, -5)]
|
||||
[InlineData(8, -1)]
|
||||
[InlineData(9, -1)]
|
||||
[InlineData(10, 0)]
|
||||
[InlineData(11, 0)]
|
||||
[InlineData(12, 1)]
|
||||
[InlineData(15, 2)]
|
||||
[InlineData(18, 4)]
|
||||
[InlineData(20, 5)]
|
||||
[InlineData(30, 10)]
|
||||
public void Mod_MatchesD20Table(int score, int expected)
|
||||
{
|
||||
Assert.Equal(expected, AbilityScores.Mod(score));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mod_FloorsTowardNegativeInfinity()
|
||||
{
|
||||
// Score 9 → -1, score 7 → -2 (per d20 floor convention; not C# truncate)
|
||||
Assert.Equal(-1, AbilityScores.Mod(9));
|
||||
Assert.Equal(-2, AbilityScores.Mod(7));
|
||||
Assert.Equal(-3, AbilityScores.Mod(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ClampsToValidRange()
|
||||
{
|
||||
var a = new AbilityScores(0, 31, 50, -10, 100, 18);
|
||||
Assert.Equal(1, a.STR); // 0 clamped up to 1
|
||||
Assert.Equal(30, a.DEX); // 31 clamped down to 30
|
||||
Assert.Equal(30, a.CON);
|
||||
Assert.Equal(1, a.INT); // -10 clamped up to 1
|
||||
Assert.Equal(30, a.WIS);
|
||||
Assert.Equal(18, a.CHA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardArray_IsCanonical()
|
||||
{
|
||||
Assert.Equal(new[] { 15, 14, 13, 12, 10, 8 }, AbilityScores.StandardArray);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ReturnsValueByAbilityId()
|
||||
{
|
||||
var a = new AbilityScores(11, 13, 15, 10, 12, 14);
|
||||
Assert.Equal(11, a.Get(AbilityId.STR));
|
||||
Assert.Equal(13, a.Get(AbilityId.DEX));
|
||||
Assert.Equal(15, a.Get(AbilityId.CON));
|
||||
Assert.Equal(10, a.Get(AbilityId.INT));
|
||||
Assert.Equal(12, a.Get(AbilityId.WIS));
|
||||
Assert.Equal(14, a.Get(AbilityId.CHA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_ReturnsNewBlock_LeavesOriginalUnchanged()
|
||||
{
|
||||
var a = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
var b = a.With(AbilityId.STR, 18);
|
||||
Assert.Equal(10, a.STR); // unchanged
|
||||
Assert.Equal(18, b.STR);
|
||||
Assert.Equal(10, b.DEX); // others copied
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plus_AppliesAllMods()
|
||||
{
|
||||
var a = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
var mods = new Dictionary<AbilityId, int> { { AbilityId.STR, 1 }, { AbilityId.WIS, 2 } };
|
||||
var b = a.Plus(mods);
|
||||
Assert.Equal(11, b.STR);
|
||||
Assert.Equal(12, b.WIS);
|
||||
Assert.Equal(10, b.DEX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModFor_UsesStandardFormula()
|
||||
{
|
||||
var a = new AbilityScores(15, 14, 13, 12, 10, 8);
|
||||
Assert.Equal( 2, a.ModFor(AbilityId.STR)); // (15-10)/2 = 2
|
||||
Assert.Equal( 2, a.ModFor(AbilityId.DEX)); // (14-10)/2 = 2
|
||||
Assert.Equal( 1, a.ModFor(AbilityId.CON)); // (13-10)/2 = 1
|
||||
Assert.Equal( 1, a.ModFor(AbilityId.INT));
|
||||
Assert.Equal( 0, a.ModFor(AbilityId.WIS));
|
||||
Assert.Equal(-1, a.ModFor(AbilityId.CHA));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// CharacterBuilder smoke + integration: every (clade × species) pair,
|
||||
/// when assigned a representative class, produces a valid level-1 character
|
||||
/// with sane HP and ability totals.
|
||||
/// </summary>
|
||||
public sealed class CharacterBuilderTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
[Fact]
|
||||
public void Build_DefaultsProduceValidCharacter()
|
||||
{
|
||||
var c = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised").Build();
|
||||
Assert.Equal("canidae", c.Clade.Id);
|
||||
Assert.Equal("wolf", c.Species.Id);
|
||||
Assert.Equal("fangsworn", c.ClassDef.Id);
|
||||
Assert.Equal(1, c.Level);
|
||||
Assert.Equal(0, c.Xp);
|
||||
Assert.True(c.MaxHp > 0);
|
||||
Assert.Equal(c.MaxHp, c.CurrentHp);
|
||||
Assert.True(c.SkillProficiencies.Count >= c.ClassDef.SkillsChoose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AppliesCladeAndSpeciesAbilityMods()
|
||||
{
|
||||
// Wolf-Folk: STR+1 (species), CON+1 WIS+1 (canid clade)
|
||||
// Standard Array assigned in class priority — we use a known base for
|
||||
// the test instead of relying on auto-assignment.
|
||||
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
var c = b.Build();
|
||||
Assert.Equal(11, c.Abilities.STR); // 10 + species
|
||||
Assert.Equal(10, c.Abilities.DEX);
|
||||
Assert.Equal(11, c.Abilities.CON); // 10 + clade
|
||||
Assert.Equal(10, c.Abilities.INT);
|
||||
Assert.Equal(11, c.Abilities.WIS); // 10 + clade
|
||||
Assert.Equal(10, c.Abilities.CHA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_HpUsesHitDiePlusConModifier()
|
||||
{
|
||||
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 14, 10, 10, 10); // CON 14 → +2 → +1 after canid (15→+2)
|
||||
var c = b.Build();
|
||||
// Wolf-Folk has no CON mod from species, canid +1 → final CON = 15 → +2.
|
||||
// Fangsworn d10 + 2 = 12.
|
||||
Assert.Equal(12, c.MaxHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsSpeciesNotInChosenClade()
|
||||
{
|
||||
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
|
||||
b.Species = _content.Species["lion"]; // felid species under canid clade
|
||||
bool ok = b.Validate(out var err);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("clade", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresExactSkillCount()
|
||||
{
|
||||
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
|
||||
b.ChosenClassSkills.Clear();
|
||||
Assert.False(b.Validate(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsSkillNotOfferedByClass()
|
||||
{
|
||||
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
|
||||
b.ChosenClassSkills.Clear();
|
||||
b.ChosenClassSkills.Add(SkillId.Athletics);
|
||||
b.ChosenClassSkills.Add(SkillId.Arcana); // Fangsworn does not offer Arcana
|
||||
Assert.False(b.Validate(out var err));
|
||||
Assert.Contains("arcana", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("canidae", "wolf", "fangsworn", "pack_raised")]
|
||||
[InlineData("felidae", "lion", "shadow_pelt", "borderland_stray")]
|
||||
[InlineData("ursidae", "brown_bear", "feral", "coliseum_survivor")]
|
||||
[InlineData("cervidae", "elk", "covenant_keeper", "covenant_enforcer")]
|
||||
[InlineData("bovidae", "bull", "bulwark", "herd_city_born")]
|
||||
[InlineData("leporidae", "rabbit", "muzzle_speaker", "warren_runner")]
|
||||
[InlineData("mustelidae","badger", "claw_wright", "borderland_stray")]
|
||||
[InlineData("felidae", "housecat", "scent_broker", "scent_suppressed")]
|
||||
public void Build_AllRepresentativeCombosValid(
|
||||
string cladeId, string speciesId, string classId, string bgId)
|
||||
{
|
||||
var c = MinimalBuilder(cladeId, speciesId, classId, bgId).Build();
|
||||
Assert.True(c.MaxHp > 0);
|
||||
Assert.True(c.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollAbilityScores_IsDeterministicGivenSameInputs()
|
||||
{
|
||||
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
|
||||
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
|
||||
Assert.Equal(a.STR, b.STR);
|
||||
Assert.Equal(a.DEX, b.DEX);
|
||||
Assert.Equal(a.CON, b.CON);
|
||||
Assert.Equal(a.INT, b.INT);
|
||||
Assert.Equal(a.WIS, b.WIS);
|
||||
Assert.Equal(a.CHA, b.CHA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollAbilityScores_DifferentMsProducesDifferentResults()
|
||||
{
|
||||
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
|
||||
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 5678);
|
||||
// At least one of six should differ across plausibly-different rolls.
|
||||
Assert.True(
|
||||
a.STR != b.STR || a.DEX != b.DEX || a.CON != b.CON ||
|
||||
a.INT != b.INT || a.WIS != b.WIS || a.CHA != b.CHA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll4d6DropLowest_ResultIs3to18()
|
||||
{
|
||||
var rng = new SeededRng(0xDEADBEEFUL);
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
int v = CharacterBuilder.Roll4d6DropLowest(rng);
|
||||
Assert.InRange(v, 3, 18);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private CharacterBuilder MinimalBuilder(string cladeId, string speciesId, string classId, string bgId)
|
||||
{
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _content.Clades[cladeId],
|
||||
Species = _content.Species[speciesId],
|
||||
ClassDef = _content.Classes[classId],
|
||||
Background = _content.Backgrounds[bgId],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
|
||||
Name = "Test",
|
||||
};
|
||||
// Auto-pick first N skill options
|
||||
int n = b.ClassDef.SkillsChoose;
|
||||
foreach (var raw in b.ClassDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — hybrid character creation. Validates the Sire/Dam
|
||||
/// picker logic, blended ability mods, dominant-parent presentation,
|
||||
/// universal hybrid detriments (Scent Dysphoria save DC, Social Stigma
|
||||
/// penalty, Medical Incompatibility healing scale), and cross-clade
|
||||
/// enforcement.
|
||||
/// </summary>
|
||||
public sealed class HybridCharacterTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_RejectsSameClade()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "canidae", "fox");
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("different clades", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_RejectsSpeciesNotInClade()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "wolf");
|
||||
// dam species "wolf" doesn't belong to leporidae
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("clade", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_RejectsMissingSire()
|
||||
{
|
||||
var b = NewBuilderWithClassAndSkills();
|
||||
b.HybridDamClade = _content.Clades["leporidae"];
|
||||
b.HybridDamSpecies = _content.Species["rabbit"];
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("sire", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_RejectsMissingClass()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
b.ClassDef = null; // strip
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("class", err.ToLowerInvariant());
|
||||
}
|
||||
|
||||
// ── Build happy path ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_ProducesHybridCharacterWithGenealogy()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
|
||||
Assert.True(ok, err);
|
||||
Assert.NotNull(c);
|
||||
Assert.True(c!.IsHybrid);
|
||||
Assert.NotNull(c.Hybrid);
|
||||
Assert.Equal("canidae", c.Hybrid!.SireClade);
|
||||
Assert.Equal("wolf", c.Hybrid.SireSpecies);
|
||||
Assert.Equal("leporidae", c.Hybrid.DamClade);
|
||||
Assert.Equal("rabbit", c.Hybrid.DamSpecies);
|
||||
Assert.Equal(ParentLineage.Sire, c.Hybrid.DominantParent); // default
|
||||
Assert.False(c.Hybrid.PassingActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_DominantParentDrivesPresentingClade()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
b.HybridDominantParent = ParentLineage.Dam;
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||
Assert.True(ok);
|
||||
// The character's primary Clade/Species should track the dominant
|
||||
// parent so existing systems keying off Character.Clade get the
|
||||
// presenting clade.
|
||||
Assert.Equal("leporidae", c!.Clade.Id);
|
||||
Assert.Equal("rabbit", c.Species.Id);
|
||||
Assert.Equal("leporidae", c.Hybrid!.PresentingCladeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_BlendsAbilityMods()
|
||||
{
|
||||
// Wolf-Folk Sire:
|
||||
// canidae clade: +1 CON, +1 WIS
|
||||
// wolf species: +1 STR
|
||||
// × Rabbit-Folk Dam:
|
||||
// leporidae clade: -1 STR, +2 DEX
|
||||
// rabbit species: +1 WIS
|
||||
// Base 10 across the board.
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||
Assert.True(ok);
|
||||
// Net STR: 10 + 1 (wolf) - 1 (leporid) = 10.
|
||||
// Net DEX: 10 + 2 (leporid) = 12.
|
||||
// Net CON: 10 + 1 (canid) = 11.
|
||||
// Net WIS: 10 + 1 (canid) + 1 (rabbit) = 12.
|
||||
Assert.Equal(10, c!.Abilities.STR);
|
||||
Assert.Equal(12, c.Abilities.DEX);
|
||||
Assert.Equal(11, c.Abilities.CON);
|
||||
Assert.Equal(12, c.Abilities.WIS);
|
||||
}
|
||||
|
||||
// ── Cross-clade pairings smoke ────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("canidae", "wolf", "felidae", "lion")]
|
||||
[InlineData("canidae", "coyote", "leporidae", "hare")]
|
||||
[InlineData("ursidae", "brown_bear", "bovidae", "bull")]
|
||||
[InlineData("felidae", "leopard", "cervidae", "deer")]
|
||||
[InlineData("mustelidae","badger", "leporidae", "rabbit")]
|
||||
[InlineData("bovidae", "ram", "cervidae", "elk")]
|
||||
[InlineData("leporidae", "rabbit", "felidae", "housecat")]
|
||||
public void TryBuildHybrid_AllCrossCladeCombinationsValid(
|
||||
string sireClade, string sireSpecies, string damClade, string damSpecies)
|
||||
{
|
||||
var b = MakeHybridBuilder(sireClade, sireSpecies, damClade, damSpecies);
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
|
||||
Assert.True(ok, err);
|
||||
Assert.True(c!.MaxHp > 0);
|
||||
Assert.True(c.IsAlive);
|
||||
Assert.True(c.IsHybrid);
|
||||
}
|
||||
|
||||
// ── HybridDetriments ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HybridDetriments_HaveDocumentedConstants()
|
||||
{
|
||||
Assert.Equal(10, HybridDetriments.ScentDysphoriaSaveDc);
|
||||
Assert.Equal(-2, HybridDetriments.SocialStigmaFirstCheckPenalty);
|
||||
Assert.Equal(0.75f, HybridDetriments.MedicalIncompatibilityMultiplier);
|
||||
Assert.True(HybridDetriments.IllegibleBodyLanguagePenalty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleHealForHybrid_AppliesMultiplierToHybrids()
|
||||
{
|
||||
var hybrid = MakeHybrid();
|
||||
Assert.Equal(6, HybridDetriments.ScaleHealForHybrid(hybrid, 8)); // floor(8*0.75)=6
|
||||
Assert.Equal(3, HybridDetriments.ScaleHealForHybrid(hybrid, 4)); // floor(4*0.75)=3
|
||||
// Min 1 floor: a hybrid healed for 1 raw still gets 1.
|
||||
Assert.Equal(1, HybridDetriments.ScaleHealForHybrid(hybrid, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleHealForHybrid_NoOpForPurebreds()
|
||||
{
|
||||
var purebred = MakePurebred();
|
||||
Assert.Equal(8, HybridDetriments.ScaleHealForHybrid(purebred, 8));
|
||||
Assert.Equal(1, HybridDetriments.ScaleHealForHybrid(purebred, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleHealForHybrid_PassesThroughZeroAndNegative()
|
||||
{
|
||||
var hybrid = MakeHybrid();
|
||||
Assert.Equal(0, HybridDetriments.ScaleHealForHybrid(hybrid, 0));
|
||||
Assert.Equal(-3, HybridDetriments.ScaleHealForHybrid(hybrid, -3));
|
||||
}
|
||||
|
||||
// ── Save round-trip ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_RoundTripsThroughCharacterCodec()
|
||||
{
|
||||
var c = MakeHybrid();
|
||||
c.Hybrid!.PassingActive = true;
|
||||
c.Hybrid.NpcsWhoKnow.Add(42);
|
||||
c.Hybrid.NpcsWhoKnow.Add(99);
|
||||
|
||||
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(c);
|
||||
Assert.NotNull(snap.Hybrid);
|
||||
Assert.Equal("canidae", snap.Hybrid!.SireClade);
|
||||
Assert.Equal("leporidae", snap.Hybrid.DamClade);
|
||||
Assert.True(snap.Hybrid.PassingActive);
|
||||
Assert.Equal(2, snap.Hybrid.NpcsWhoKnow.Length);
|
||||
|
||||
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
|
||||
Assert.NotNull(restored.Hybrid);
|
||||
Assert.Equal("canidae", restored.Hybrid!.SireClade);
|
||||
Assert.Equal("wolf", restored.Hybrid.SireSpecies);
|
||||
Assert.Equal("leporidae", restored.Hybrid.DamClade);
|
||||
Assert.Equal("rabbit", restored.Hybrid.DamSpecies);
|
||||
Assert.True(restored.Hybrid.PassingActive);
|
||||
Assert.Contains(42, restored.Hybrid.NpcsWhoKnow);
|
||||
Assert.Contains(99, restored.Hybrid.NpcsWhoKnow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Purebred_RoundTripDoesNotEmitHybridSection()
|
||||
{
|
||||
var c = MakePurebred();
|
||||
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(c);
|
||||
Assert.Null(snap.Hybrid);
|
||||
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
|
||||
Assert.Null(restored.Hybrid);
|
||||
Assert.False(restored.IsHybrid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_RoundTripsThroughBinarySaveCodec()
|
||||
{
|
||||
var c = MakeHybrid();
|
||||
c.Hybrid!.PassingActive = true;
|
||||
|
||||
var header = new Theriapolis.Core.Persistence.SaveHeader
|
||||
{
|
||||
Version = Theriapolis.Core.C.SAVE_SCHEMA_VERSION,
|
||||
WorldSeedHex = "0xFEED",
|
||||
};
|
||||
var body = new Theriapolis.Core.Persistence.SaveBody
|
||||
{
|
||||
PlayerCharacter = Theriapolis.Core.Persistence.CharacterCodec.Capture(c),
|
||||
};
|
||||
body.Player.Id = 1;
|
||||
body.Player.Name = "Hybrid";
|
||||
|
||||
var bytes = Theriapolis.Core.Persistence.SaveCodec.Serialize(header, body);
|
||||
var (h2, body2) = Theriapolis.Core.Persistence.SaveCodec.Deserialize(bytes);
|
||||
|
||||
Assert.Equal(header.Version, h2.Version);
|
||||
Assert.NotNull(body2.PlayerCharacter);
|
||||
Assert.NotNull(body2.PlayerCharacter!.Hybrid);
|
||||
Assert.Equal("canidae", body2.PlayerCharacter.Hybrid!.SireClade);
|
||||
Assert.Equal("leporidae", body2.PlayerCharacter.Hybrid.DamClade);
|
||||
Assert.True(body2.PlayerCharacter.Hybrid.PassingActive);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private CharacterBuilder NewBuilderWithClassAndSkills()
|
||||
{
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
ClassDef = _content.Classes["fangsworn"],
|
||||
Background = _content.Backgrounds["pack_raised"],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
|
||||
Name = "Test",
|
||||
IsHybridOrigin = true,
|
||||
};
|
||||
int n = b.ClassDef.SkillsChoose;
|
||||
foreach (var raw in b.ClassDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private CharacterBuilder MakeHybridBuilder(
|
||||
string sireClade, string sireSpecies,
|
||||
string damClade, string damSpecies)
|
||||
{
|
||||
var b = NewBuilderWithClassAndSkills();
|
||||
b.HybridSireClade = _content.Clades[sireClade];
|
||||
b.HybridSireSpecies = _content.Species[sireSpecies];
|
||||
b.HybridDamClade = _content.Clades[damClade];
|
||||
b.HybridDamSpecies = _content.Species[damSpecies];
|
||||
return b;
|
||||
}
|
||||
|
||||
private Character MakeHybrid()
|
||||
{
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
Assert.True(b.TryBuildHybrid(_content.Items, out var c, out _));
|
||||
return c!;
|
||||
}
|
||||
|
||||
private Character MakePurebred()
|
||||
{
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _content.Clades["canidae"],
|
||||
Species = _content.Species["wolf"],
|
||||
ClassDef = _content.Classes["fangsworn"],
|
||||
Background = _content.Backgrounds["pack_raised"],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
|
||||
Name = "Test",
|
||||
};
|
||||
int n = b.ClassDef.SkillsChoose;
|
||||
foreach (var raw in b.ClassDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b.Build(_content.Items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — LevelUpFlow + Character.ApplyLevelUp coverage.
|
||||
///
|
||||
/// LevelUpFlow.Compute is pure; same (character, level, seed) → same payload.
|
||||
/// ApplyLevelUp mutates in place; per-level deltas land on Level, MaxHp,
|
||||
/// CurrentHp, LearnedFeatureIds, LevelUpHistory, and (when applicable)
|
||||
/// SubclassId / Abilities.
|
||||
/// </summary>
|
||||
public sealed class LevelUpFlowTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
private Character MakeWolfFangsworn(int con = 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(15, 12, con, 10, 13, 8),
|
||||
Name = "Test",
|
||||
};
|
||||
int n = b.ClassDef.SkillsChoose;
|
||||
foreach (var raw in b.ClassDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanLevelUp_FalseAtZeroXp()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
Assert.Equal(1, c.Level);
|
||||
Assert.Equal(0, c.Xp);
|
||||
Assert.False(LevelUpFlow.CanLevelUp(c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanLevelUp_TrueAt300Xp()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Xp = 300;
|
||||
Assert.True(LevelUpFlow.CanLevelUp(c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanLevelUp_FalseAtLevelCap()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = C.CHARACTER_LEVEL_MAX;
|
||||
c.Xp = 999_999;
|
||||
Assert.False(LevelUpFlow.CanLevelUp(c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_TakeAverage_ReturnsExpectedHpGain()
|
||||
{
|
||||
var c = MakeWolfFangsworn(con: 14); // Wolf+canid: con+1 → 15 → +2 mod
|
||||
var result = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0xCAFE, takeAverage: true);
|
||||
// Fangsworn d10: average rounded up = 6. CON mod = +2. → 8
|
||||
Assert.Equal(2, result.NewLevel);
|
||||
Assert.Equal(8, result.HpGained);
|
||||
Assert.True(result.HpWasAveraged);
|
||||
Assert.False(result.GrantsAsiChoice);
|
||||
Assert.False(result.GrantsSubclassChoice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_RolledHp_IsDeterministicForSameSeed()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
var a = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
|
||||
var b = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
|
||||
Assert.Equal(a.HpGained, b.HpGained);
|
||||
Assert.Equal(a.HpHitDieResult, b.HpHitDieResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_RolledHp_DifferentSeedsCanProduceDifferentResults()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
var seen = new HashSet<int>();
|
||||
for (ulong s = 1; s <= 50; s++)
|
||||
{
|
||||
var r = LevelUpFlow.Compute(c, targetLevel: 2, seed: s, takeAverage: false);
|
||||
seen.Add(r.HpHitDieResult);
|
||||
}
|
||||
// With 50 different seeds and a d10, we should see at least 3 distinct rolls.
|
||||
Assert.True(seen.Count >= 3, $"Expected variance across 50 seeds; saw {seen.Count} distinct rolls.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_Level3_GrantsSubclassChoice()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 2;
|
||||
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
|
||||
Assert.True(result.GrantsSubclassChoice);
|
||||
// Fangsworn has at least one subclass id in classes.json.
|
||||
Assert.NotEmpty(c.ClassDef.SubclassIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_Level3_DoesNotGrantSubclassChoice_IfAlreadyPicked()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 2;
|
||||
c.SubclassId = "pack_forged"; // already picked somehow
|
||||
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
|
||||
Assert.False(result.GrantsSubclassChoice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_Level4_GrantsAsiChoice()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 3;
|
||||
var result = LevelUpFlow.Compute(c, targetLevel: 4, seed: 0xCAFE);
|
||||
Assert.True(result.GrantsAsiChoice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_ProficiencyBonus_FollowsTable()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
Assert.Equal(2, LevelUpFlow.Compute(c, targetLevel: 2, seed: 0).NewProficiencyBonus);
|
||||
Assert.Equal(3, LevelUpFlow.Compute(c, targetLevel: 5, seed: 0).NewProficiencyBonus);
|
||||
Assert.Equal(4, LevelUpFlow.Compute(c, targetLevel: 9, seed: 0).NewProficiencyBonus);
|
||||
Assert.Equal(5, LevelUpFlow.Compute(c, targetLevel: 13, seed: 0).NewProficiencyBonus);
|
||||
Assert.Equal(6, LevelUpFlow.Compute(c, targetLevel: 17, seed: 0).NewProficiencyBonus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_FeaturesUnlocked_FollowsLevelTable()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
var lv2 = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0);
|
||||
// Fangsworn level 2 grants Action Surge per the level table.
|
||||
Assert.NotEmpty(lv2.ClassFeaturesUnlocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_MutatesLevelHpAndHistory()
|
||||
{
|
||||
var c = MakeWolfFangsworn(con: 10); // canid +1 → CON 11 → mod 0
|
||||
int hpBefore = c.MaxHp;
|
||||
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
|
||||
c.ApplyLevelUp(result, new LevelUpChoices { TakeAverageHp = true });
|
||||
|
||||
Assert.Equal(2, c.Level);
|
||||
Assert.Equal(hpBefore + result.HpGained, c.MaxHp);
|
||||
Assert.Equal(c.MaxHp, c.CurrentHp); // level-up restores HP
|
||||
Assert.Single(c.LevelUpHistory);
|
||||
Assert.Equal(2, c.LevelUpHistory[0].Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_RecordsClassFeatures()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
|
||||
int beforeCount = c.LearnedFeatureIds.Count;
|
||||
c.ApplyLevelUp(result, new LevelUpChoices());
|
||||
Assert.Equal(beforeCount + result.ClassFeaturesUnlocked.Length, c.LearnedFeatureIds.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_WithSubclassChoice_WritesSubclassId()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 2;
|
||||
c.Xp = XpTable.Threshold[3];
|
||||
var result = LevelUpFlow.Compute(c, 3, 0xCAFE);
|
||||
var subId = c.ClassDef.SubclassIds[0];
|
||||
c.ApplyLevelUp(result, new LevelUpChoices { SubclassId = subId });
|
||||
Assert.Equal(subId, c.SubclassId);
|
||||
Assert.Equal(subId, c.LevelUpHistory[^1].SubclassChosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_WithAsi_RaisesAbilityScores()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 3;
|
||||
c.Xp = XpTable.Threshold[4];
|
||||
int strBefore = c.Abilities.STR;
|
||||
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
|
||||
c.ApplyLevelUp(result, new LevelUpChoices
|
||||
{
|
||||
AsiAdjustments = new() { { AbilityId.STR, 2 } },
|
||||
});
|
||||
Assert.Equal(strBefore + 2, c.Abilities.STR);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_AsiClampsAtAbilityCap()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
c.Level = 3;
|
||||
c.SetAbilities(c.Abilities.With(AbilityId.STR, C.ABILITY_SCORE_CAP_PRE_L20));
|
||||
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
|
||||
c.ApplyLevelUp(result, new LevelUpChoices
|
||||
{
|
||||
AsiAdjustments = new() { { AbilityId.STR, 2 } },
|
||||
});
|
||||
Assert.Equal(C.ABILITY_SCORE_CAP_PRE_L20, c.Abilities.STR);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLevelUp_ChainedLevels_AccumulateHistoryInOrder()
|
||||
{
|
||||
var c = MakeWolfFangsworn();
|
||||
for (int target = 2; target <= 5; target++)
|
||||
{
|
||||
var r = LevelUpFlow.Compute(c, target, 0xCAFE_CAFE_CAFEUL ^ (ulong)target);
|
||||
var choices = new LevelUpChoices();
|
||||
if (r.GrantsSubclassChoice)
|
||||
choices.SubclassId = c.ClassDef.SubclassIds[0];
|
||||
if (r.GrantsAsiChoice)
|
||||
choices.AsiAdjustments = new() { { AbilityId.CON, 2 } };
|
||||
c.ApplyLevelUp(r, choices);
|
||||
}
|
||||
Assert.Equal(5, c.Level);
|
||||
Assert.Equal(4, c.LevelUpHistory.Count);
|
||||
for (int i = 0; i < 4; i++)
|
||||
Assert.Equal(i + 2, c.LevelUpHistory[i].Level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
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.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M5 — passing detection. Hybrid PCs with PassingActive get a
|
||||
/// scent-detection roll on encountering scent-capable NPCs. The result is
|
||||
/// permanent per-NPC (cached in Hybrid.NpcsWhoKnow + the NPC's
|
||||
/// PersonalDisposition.Memory). Once detected, EffectiveDisposition layers
|
||||
/// in the NPC's BiasProfile.HybridBias.
|
||||
/// </summary>
|
||||
public sealed class PassingDetectionTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
// ── Roll outcomes ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Roll_NotApplicable_ForPurebredPc()
|
||||
{
|
||||
var pc = MakePurebred();
|
||||
var npc = MakeCanidNpc();
|
||||
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
|
||||
Assert.Equal(DetectionResult.NotApplicable, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_PreviouslyDetected_NoFreshRoll()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeCanidNpc();
|
||||
var memory = new HashSet<string> { "knows_hybrid" };
|
||||
var result = PassingCheck.Roll(pc, npc, memory, seed: 0xCAFE);
|
||||
Assert.Equal(DetectionResult.PreviouslyDetected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_NotPassing_AutoDetected()
|
||||
{
|
||||
var pc = MakeHybrid(passing: false);
|
||||
var npc = MakeCanidNpc();
|
||||
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
|
||||
Assert.Equal(DetectionResult.NotPassing, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_DeepCoverMask_AlwaysSuppresses()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover;
|
||||
var npc = MakeCanidNpc();
|
||||
// Even Canid Superior Scent fails against deep cover.
|
||||
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
|
||||
Assert.Equal(DetectionResult.MaskSuppressed, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_MilitaryMask_SuppressesNonCanid()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military;
|
||||
// M5 simplification: only Canid NPCs detect scent. Test the path
|
||||
// by giving the NPC a non-Canid clade — military mask suppresses
|
||||
// automatically for non-superior-scent NPCs.
|
||||
var npc = MakeNonCanidNpc();
|
||||
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
|
||||
// Non-Canid NPCs lack scent capability anyway, so result is NoCapability.
|
||||
// Military mask short-circuits earlier with MaskSuppressed.
|
||||
Assert.Equal(DetectionResult.MaskSuppressed, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_NoCapability_ForNonScentNpc()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeNonCanidNpc();
|
||||
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
|
||||
Assert.Equal(DetectionResult.NoCapability, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_IsDeterministic_ForSameSeed()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeCanidNpc();
|
||||
// Use a fresh memory set each time so PreviouslyDetected doesn't
|
||||
// short-circuit.
|
||||
var a = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0x1234);
|
||||
var b = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0x1234);
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_DifferentSeeds_CanProduceDifferentOutcomes()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeCanidNpc();
|
||||
// Sweep 50 seeds — at least one should differ from the first
|
||||
// (probabilistic detection).
|
||||
var first = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 1UL);
|
||||
bool sawDifferent = false;
|
||||
for (ulong s = 2; s <= 50; s++)
|
||||
{
|
||||
var r = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: s);
|
||||
if (r != first) { sawDifferent = true; break; }
|
||||
}
|
||||
Assert.True(sawDifferent, "expected some seed variance in detection outcomes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_BasicMaskFavoursPc()
|
||||
{
|
||||
// With a basic mask, the PC's Deception roll gets +5. Sweep many
|
||||
// seeds and verify that masked rolls produce *more* Pass outcomes
|
||||
// than unmasked rolls on average.
|
||||
var pcMasked = MakeHybrid(passing: true);
|
||||
pcMasked.Hybrid!.ActiveMaskTier = ScentMaskTier.Basic;
|
||||
var pcUnmasked = MakeHybrid(passing: true);
|
||||
var npc = MakeCanidNpc();
|
||||
|
||||
int maskedPasses = 0;
|
||||
int unmaskedPasses = 0;
|
||||
for (ulong s = 1; s <= 200; s++)
|
||||
{
|
||||
if (PassingCheck.Roll(pcMasked, npc, new HashSet<string>(), seed: s) == DetectionResult.Pass)
|
||||
maskedPasses++;
|
||||
if (PassingCheck.Roll(pcUnmasked, npc, new HashSet<string>(), seed: s) == DetectionResult.Pass)
|
||||
unmaskedPasses++;
|
||||
}
|
||||
Assert.True(maskedPasses > unmaskedPasses,
|
||||
$"basic mask should help: masked passes={maskedPasses}, unmasked passes={unmaskedPasses}");
|
||||
}
|
||||
|
||||
// ── RollAndApply side effects ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RollAndApply_NotPassing_WritesMemoryAndLedger()
|
||||
{
|
||||
var pc = MakeHybrid(passing: false);
|
||||
var npc = MakeCanidNpcWithRole("test.canid");
|
||||
var rep = new PlayerReputation();
|
||||
|
||||
var result = PassingCheck.RollAndApply(pc, npc, rep,
|
||||
worldClockSeconds: 100L, seed: 0xCAFE);
|
||||
|
||||
Assert.Equal(DetectionResult.NotPassing, result);
|
||||
Assert.Contains(npc.Id, pc.Hybrid!.NpcsWhoKnow);
|
||||
Assert.Contains("knows_hybrid", rep.PersonalFor("test.canid").Memory);
|
||||
Assert.Contains(rep.Ledger.Entries,
|
||||
ev => ev.Kind == RepEventKind.HybridDetected && ev.RoleTag == "test.canid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollAndApply_PreviouslyDetected_DoesNotReWrite()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeCanidNpcWithRole("test.canid");
|
||||
var rep = new PlayerReputation();
|
||||
// Pre-seed memory to look like a prior detection.
|
||||
rep.PersonalFor("test.canid").Memory.Add("knows_hybrid");
|
||||
|
||||
var result = PassingCheck.RollAndApply(pc, npc, rep,
|
||||
worldClockSeconds: 100L, seed: 0xCAFE);
|
||||
|
||||
Assert.Equal(DetectionResult.PreviouslyDetected, result);
|
||||
// No new HybridDetected event added (only the pre-existing memory tag).
|
||||
Assert.DoesNotContain(rep.Ledger.Entries,
|
||||
ev => ev.Kind == RepEventKind.HybridDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollAndApply_NotApplicable_NoSideEffects_ForPurebred()
|
||||
{
|
||||
var pc = MakePurebred();
|
||||
var npc = MakeCanidNpcWithRole("test.canid");
|
||||
var rep = new PlayerReputation();
|
||||
|
||||
var result = PassingCheck.RollAndApply(pc, npc, rep,
|
||||
worldClockSeconds: 100L, seed: 0xCAFE);
|
||||
|
||||
Assert.Equal(DetectionResult.NotApplicable, result);
|
||||
Assert.Empty(rep.Ledger.Entries);
|
||||
Assert.False(rep.Personal.ContainsKey("test.canid"));
|
||||
}
|
||||
|
||||
// ── EffectiveDisposition + HybridBias consumption ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDisposition_DoesNotApplyHybridBias_BeforeDetection()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS");
|
||||
var rep = new PlayerReputation();
|
||||
// No detection yet — hybrid bias should not be in the disposition.
|
||||
int beforeDisposition = EffectiveDisposition.For(npc, pc, rep, _content);
|
||||
// Sanity: just confirm we get a number; what matters is the next
|
||||
// assertion shows it differs after detection.
|
||||
Assert.True(beforeDisposition > -100 && beforeDisposition < 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDisposition_AppliesHybridBias_AfterDetection()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS");
|
||||
var rep = new PlayerReputation();
|
||||
|
||||
int before = EffectiveDisposition.For(npc, pc, rep, _content);
|
||||
// Mark NPC as having detected.
|
||||
pc.Hybrid!.NpcsWhoKnow.Add(npc.Id);
|
||||
int after = EffectiveDisposition.For(npc, pc, rep, _content);
|
||||
|
||||
// CERVID_CAUTIOUS has a *negative* hybrid_bias per bias_profiles.json,
|
||||
// so the disposition should drop after detection.
|
||||
Assert.True(after < before,
|
||||
$"expected disposition to drop after hybrid detection: before={before}, after={after}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDisposition_ProgressiveProfile_PositiveHybridBias()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
var npc = MakeNpcWithBiasProfile("HYBRID_SURVIVOR");
|
||||
var rep = new PlayerReputation();
|
||||
|
||||
int before = EffectiveDisposition.For(npc, pc, rep, _content);
|
||||
pc.Hybrid!.NpcsWhoKnow.Add(npc.Id);
|
||||
int after = EffectiveDisposition.For(npc, pc, rep, _content);
|
||||
|
||||
// HYBRID_SURVIVOR has positive hybrid_bias — disposition rises.
|
||||
Assert.True(after > before,
|
||||
$"expected disposition to rise for hybrid-friendly profile: before={before}, after={after}");
|
||||
}
|
||||
|
||||
// ── Save round-trip mask tier ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_MaskTier_RoundTripsThroughSave()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover;
|
||||
|
||||
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc);
|
||||
Assert.NotNull(snap.Hybrid);
|
||||
Assert.Equal((byte)ScentMaskTier.DeepCover, snap.Hybrid!.ActiveMaskTier);
|
||||
|
||||
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
|
||||
Assert.Equal(ScentMaskTier.DeepCover, restored.Hybrid!.ActiveMaskTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_MaskTier_RoundTripsThroughBinarySaveCodec()
|
||||
{
|
||||
var pc = MakeHybrid(passing: true);
|
||||
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military;
|
||||
|
||||
var header = new Theriapolis.Core.Persistence.SaveHeader
|
||||
{
|
||||
Version = Theriapolis.Core.C.SAVE_SCHEMA_VERSION,
|
||||
WorldSeedHex = "0xFEED",
|
||||
};
|
||||
var body = new Theriapolis.Core.Persistence.SaveBody
|
||||
{
|
||||
PlayerCharacter = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc),
|
||||
};
|
||||
body.Player.Id = 1;
|
||||
body.Player.Name = "Hybrid";
|
||||
|
||||
var bytes = Theriapolis.Core.Persistence.SaveCodec.Serialize(header, body);
|
||||
var (_, body2) = Theriapolis.Core.Persistence.SaveCodec.Deserialize(bytes);
|
||||
|
||||
Assert.NotNull(body2.PlayerCharacter?.Hybrid);
|
||||
Assert.Equal((byte)ScentMaskTier.Military, body2.PlayerCharacter!.Hybrid!.ActiveMaskTier);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private Theriapolis.Core.Rules.Character.Character MakePurebred()
|
||||
{
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _content.Clades["canidae"],
|
||||
Species = _content.Species["wolf"],
|
||||
ClassDef = _content.Classes["fangsworn"],
|
||||
Background = _content.Backgrounds["pack_raised"],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
|
||||
};
|
||||
AutoPickSkills(b);
|
||||
return b.Build(_content.Items);
|
||||
}
|
||||
|
||||
private Theriapolis.Core.Rules.Character.Character MakeHybrid(bool passing)
|
||||
{
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
ClassDef = _content.Classes["fangsworn"],
|
||||
Background = _content.Backgrounds["pack_raised"],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 12),
|
||||
IsHybridOrigin = true,
|
||||
HybridSireClade = _content.Clades["canidae"],
|
||||
HybridSireSpecies = _content.Species["wolf"],
|
||||
HybridDamClade = _content.Clades["leporidae"],
|
||||
HybridDamSpecies = _content.Species["rabbit"],
|
||||
HybridDominantParent = ParentLineage.Sire,
|
||||
};
|
||||
AutoPickSkills(b);
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
|
||||
Assert.True(ok, err);
|
||||
c!.Hybrid!.PassingActive = passing;
|
||||
return c;
|
||||
}
|
||||
|
||||
private void AutoPickSkills(CharacterBuilder b)
|
||||
{
|
||||
int n = b.ClassDef!.SkillsChoose;
|
||||
foreach (var raw in b.ClassDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private NpcActor MakeCanidNpc()
|
||||
{
|
||||
var resident = new ResidentTemplateDef
|
||||
{
|
||||
Id = "test_canid",
|
||||
Name = "Test Canid",
|
||||
Clade = "canidae",
|
||||
Species = "wolf",
|
||||
};
|
||||
return new NpcActor(resident) { Id = 42 };
|
||||
}
|
||||
|
||||
private NpcActor MakeCanidNpcWithRole(string roleTag)
|
||||
{
|
||||
var resident = new ResidentTemplateDef
|
||||
{
|
||||
Id = "test_canid",
|
||||
Name = "Test Canid",
|
||||
Clade = "canidae",
|
||||
Species = "wolf",
|
||||
RoleTag = roleTag,
|
||||
};
|
||||
return new NpcActor(resident) { Id = 42 };
|
||||
}
|
||||
|
||||
private NpcActor MakeNonCanidNpc()
|
||||
{
|
||||
var resident = new ResidentTemplateDef
|
||||
{
|
||||
Id = "test_cervid",
|
||||
Name = "Test Cervid",
|
||||
Clade = "cervidae",
|
||||
Species = "deer",
|
||||
};
|
||||
return new NpcActor(resident) { Id = 99 };
|
||||
}
|
||||
|
||||
private NpcActor MakeNpcWithBiasProfile(string biasProfileId)
|
||||
{
|
||||
var resident = new ResidentTemplateDef
|
||||
{
|
||||
Id = "test_npc",
|
||||
Name = "Test NPC",
|
||||
Clade = "cervidae",
|
||||
Species = "deer",
|
||||
BiasProfile = biasProfileId,
|
||||
};
|
||||
return new NpcActor(resident) { Id = 1 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
public sealed class ProficiencyBonusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, 2)]
|
||||
[InlineData(2, 2)]
|
||||
[InlineData(3, 2)]
|
||||
[InlineData(4, 2)]
|
||||
[InlineData(5, 3)]
|
||||
[InlineData(6, 3)]
|
||||
[InlineData(8, 3)]
|
||||
[InlineData(9, 4)]
|
||||
[InlineData(12, 4)]
|
||||
[InlineData(13, 5)]
|
||||
[InlineData(16, 5)]
|
||||
[InlineData(17, 6)]
|
||||
[InlineData(20, 6)]
|
||||
public void ForLevel_MatchesD20Table(int level, int expected)
|
||||
{
|
||||
Assert.Equal(expected, ProficiencyBonus.ForLevel(level));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(21)]
|
||||
[InlineData(100)]
|
||||
public void ForLevel_OutOfRange_Throws(int level)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ProficiencyBonus.ForLevel(level));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
public sealed class SizeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SizeCategory.Small, 1)]
|
||||
[InlineData(SizeCategory.Medium, 1)]
|
||||
[InlineData(SizeCategory.MediumLarge, 1)]
|
||||
[InlineData(SizeCategory.Large, 2)]
|
||||
public void FootprintTiles_MatchesPlanTable(SizeCategory s, int expected)
|
||||
{
|
||||
Assert.Equal(expected, s.FootprintTiles());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SizeCategory.Small, 1)]
|
||||
[InlineData(SizeCategory.Medium, 1)]
|
||||
[InlineData(SizeCategory.MediumLarge, 1)]
|
||||
[InlineData(SizeCategory.Large, 2)]
|
||||
public void DefaultReachTiles_MatchesPlanTable(SizeCategory s, int expected)
|
||||
{
|
||||
Assert.Equal(expected, s.DefaultReachTiles());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", SizeCategory.Small)]
|
||||
[InlineData("medium", SizeCategory.Medium)]
|
||||
[InlineData("medium_large", SizeCategory.MediumLarge)]
|
||||
[InlineData("large", SizeCategory.Large)]
|
||||
public void FromJson_ParsesSnakeCase(string raw, SizeCategory expected)
|
||||
{
|
||||
Assert.Equal(expected, SizeExtensions.FromJson(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_UnknownThrows()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SizeExtensions.FromJson("gargantuan"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarryCapacityMult_LargeIsDoubled()
|
||||
{
|
||||
Assert.Equal(2.0f, SizeCategory.Large.CarryCapacityMult());
|
||||
Assert.Equal(0.5f, SizeCategory.Small.CarryCapacityMult());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that every class's <c>starting_kit</c> in classes.json
|
||||
/// references real items, that auto-equipped items land in their declared
|
||||
/// slot, and that fresh characters arrive armed and armoured.
|
||||
/// </summary>
|
||||
public sealed class StartingKitTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
[Fact]
|
||||
public void EveryClass_HasNonEmptyStartingKit()
|
||||
{
|
||||
foreach (var c in _content.Classes.Values)
|
||||
Assert.NotEmpty(c.StartingKit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryStartingKitItem_ReferencesARealItem()
|
||||
{
|
||||
foreach (var c in _content.Classes.Values)
|
||||
foreach (var entry in c.StartingKit)
|
||||
Assert.True(
|
||||
_content.Items.ContainsKey(entry.ItemId),
|
||||
$"Class '{c.Id}' starting_kit references unknown item '{entry.ItemId}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryAutoEquipEntry_HasValidEquipSlot()
|
||||
{
|
||||
foreach (var c in _content.Classes.Values)
|
||||
foreach (var entry in c.StartingKit)
|
||||
{
|
||||
if (!entry.AutoEquip) continue;
|
||||
Assert.False(
|
||||
string.IsNullOrEmpty(entry.EquipSlot),
|
||||
$"Class '{c.Id}' auto-equip entry '{entry.ItemId}' has empty equip_slot");
|
||||
Assert.NotNull(EquipSlotExtensions.FromJson(entry.EquipSlot));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fangsworn", "rend_sword", "chain_shirt", "buckler")]
|
||||
[InlineData("bulwark", "hoof_club", "chain_mail", "standard_shield")]
|
||||
[InlineData("covenant_keeper", "rend_sword", "chain_shirt", "standard_shield")]
|
||||
[InlineData("claw_wright", "hoof_club", "studded_leather", "buckler")]
|
||||
public void StartingKit_AppliedAndEquipped_FullKit(
|
||||
string classId, string mainHand, string body, string offHand)
|
||||
{
|
||||
var c = BuildWithKit(classId);
|
||||
AssertEquipped(c, EquipSlot.MainHand, mainHand);
|
||||
AssertEquipped(c, EquipSlot.Body, body);
|
||||
AssertEquipped(c, EquipSlot.OffHand, offHand);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("feral", "paw_axe", "hide_vest")]
|
||||
[InlineData("shadow_pelt", "thorn_blade", "studded_leather")]
|
||||
[InlineData("scent_broker", "fang_knife", "leather_harness")]
|
||||
[InlineData("muzzle_speaker", "fang_knife", "studded_leather")]
|
||||
public void StartingKit_AppliedAndEquipped_NoShield(string classId, string mainHand, string body)
|
||||
{
|
||||
var c = BuildWithKit(classId);
|
||||
AssertEquipped(c, EquipSlot.MainHand, mainHand);
|
||||
AssertEquipped(c, EquipSlot.Body, body);
|
||||
Assert.Null(c.Inventory.GetEquipped(EquipSlot.OffHand));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartingKit_Skipped_WhenItemsTableNotPassed()
|
||||
{
|
||||
var c = MakeBuilder("fangsworn").Build(); // no items dict → no kit applied
|
||||
Assert.Empty(c.Inventory.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartingKit_ProducesPositiveAcOverUnarmoredBaseline()
|
||||
{
|
||||
var c = BuildWithKit("fangsworn");
|
||||
int armored = DerivedStats.ArmorClass(c);
|
||||
Assert.True(armored >= 14, $"Fangsworn starting kit should produce AC ≥ 14 (chain shirt + buckler), got {armored}");
|
||||
}
|
||||
|
||||
private Character BuildWithKit(string classId)
|
||||
=> MakeBuilder(classId).Build(_content.Items);
|
||||
|
||||
private CharacterBuilder MakeBuilder(string classId)
|
||||
{
|
||||
var classDef = _content.Classes[classId];
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _content.Clades["canidae"],
|
||||
Species = _content.Species["wolf"],
|
||||
ClassDef = classDef,
|
||||
Background = _content.Backgrounds["pack_raised"],
|
||||
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
|
||||
Name = "KitTest",
|
||||
};
|
||||
// Pick the right number of skills for this class.
|
||||
int n = classDef.SkillsChoose;
|
||||
foreach (var raw in classDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private static void AssertEquipped(Character c, EquipSlot slot, string expectedItemId)
|
||||
{
|
||||
var inst = c.Inventory.GetEquipped(slot);
|
||||
Assert.NotNull(inst);
|
||||
Assert.Equal(expectedItemId, inst!.Def.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — SubclassResolver covers the pure look-up surface
|
||||
/// (subclass id → unlocked feature ids per level, feature def lookup).
|
||||
/// All subclass mechanics are JSON-driven; tests run against the live
|
||||
/// content set so authoring drift is caught here.
|
||||
/// </summary>
|
||||
public sealed class SubclassResolverTests
|
||||
{
|
||||
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
||||
|
||||
[Fact]
|
||||
public void TryFindSubclass_ReturnsDefForKnownId()
|
||||
{
|
||||
var def = SubclassResolver.TryFindSubclass(_content.Subclasses, "pack_forged");
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal("fangsworn", def!.ClassId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindSubclass_NullForEmptyId()
|
||||
{
|
||||
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, ""));
|
||||
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindSubclass_NullForUnknownId()
|
||||
{
|
||||
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, "definitely_not_a_subclass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockedFeaturesAt_Level3_ReturnsFirstFeature()
|
||||
{
|
||||
var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 3);
|
||||
Assert.NotEmpty(features);
|
||||
Assert.Contains("packmates_howl", features);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockedFeaturesAt_LevelWithoutEntry_ReturnsEmpty()
|
||||
{
|
||||
// Pack-Forged has entries at L3, L7, L10, L15, L18 — L4 is empty.
|
||||
var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 4);
|
||||
Assert.Empty(features);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockedFeaturesAt_NullSubclassId_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, null, 3));
|
||||
Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "", 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFeatureDef_FindsSubclassFeature()
|
||||
{
|
||||
var subclass = _content.Subclasses["pack_forged"];
|
||||
var classDef = _content.Classes["fangsworn"];
|
||||
var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "packmates_howl");
|
||||
Assert.NotNull(fdef);
|
||||
Assert.Equal("Packmate's Howl", fdef!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFeatureDef_FallsThroughToClassDefForSharedIds()
|
||||
{
|
||||
var subclass = _content.Subclasses["pack_forged"];
|
||||
var classDef = _content.Classes["fangsworn"];
|
||||
// 'asi' is in the class feature_definitions (shared across subclasses).
|
||||
var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "asi");
|
||||
Assert.NotNull(fdef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFeatureDef_NullForUnknownId()
|
||||
{
|
||||
var subclass = _content.Subclasses["pack_forged"];
|
||||
var classDef = _content.Classes["fangsworn"];
|
||||
Assert.Null(SubclassResolver.ResolveFeatureDef(classDef, subclass, "completely_made_up_feature"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryClass_HasAtLeastOneSubclass()
|
||||
{
|
||||
// Smoke: every class declared in classes.json should resolve to at
|
||||
// least one entry in subclasses.json.
|
||||
foreach (var cls in _content.Classes.Values)
|
||||
{
|
||||
Assert.NotEmpty(cls.SubclassIds);
|
||||
foreach (var sid in cls.SubclassIds)
|
||||
{
|
||||
Assert.True(_content.Subclasses.ContainsKey(sid),
|
||||
$"class '{cls.Id}' references unknown subclass '{sid}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EverySubclass_HasLevel3Features()
|
||||
{
|
||||
// M2 ship-point: every authored subclass should have at least one
|
||||
// level-3 feature so the L3 unlock fires meaningfully.
|
||||
foreach (var sub in _content.Subclasses.Values)
|
||||
{
|
||||
var l3 = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, sub.Id, 3);
|
||||
Assert.NotEmpty(l3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Xunit;
|
||||
|
||||
namespace Theriapolis.Tests.Rules;
|
||||
|
||||
public sealed class XpTableTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(299, 1)]
|
||||
[InlineData(300, 2)]
|
||||
[InlineData(899, 2)]
|
||||
[InlineData(900, 3)]
|
||||
[InlineData(2_700, 4)]
|
||||
[InlineData(355_000, 20)]
|
||||
[InlineData(1_000_000,20)]
|
||||
public void LevelForXp_MatchesD20Table(int xp, int expectedLevel)
|
||||
{
|
||||
Assert.Equal(expectedLevel, XpTable.LevelForXp(xp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelForXp_NegativeThrows()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => XpTable.LevelForXp(-1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 300)]
|
||||
[InlineData(2, 900)]
|
||||
[InlineData(19, 355_000)]
|
||||
public void XpRequiredForNextLevel_MatchesTable(int currentLevel, int expectedNext)
|
||||
{
|
||||
Assert.Equal(expectedNext, XpTable.XpRequiredForNextLevel(currentLevel));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XpRequiredForNextLevel_AtCap_ReturnsMaxValue()
|
||||
{
|
||||
Assert.Equal(int.MaxValue, XpTable.XpRequiredForNextLevel(20));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Threshold_IsMonotonicallyIncreasing()
|
||||
{
|
||||
for (int lv = 2; lv <= 20; lv++)
|
||||
Assert.True(XpTable.Threshold[lv] > XpTable.Threshold[lv - 1],
|
||||
$"Threshold[{lv}]={XpTable.Threshold[lv]} should be > Threshold[{lv-1}]={XpTable.Threshold[lv-1]}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user