using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; using Xunit; namespace Theriapolis.Tests.Rules; /// /// 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. /// 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", "sheep", "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); } }