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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -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);
}
}
+240
View File
@@ -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));
}
}
+50
View File
@@ -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());
}
}
+122
View File
@@ -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);
}
}
}
+51
View File
@@ -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]}");
}
}