2026-04-30 20:40:51 -07:00
|
|
|
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]
|
2026-05-09 21:26:16 -07:00
|
|
|
public void TryBuildHybrid_AppliesChosenAbilityFromEachParentClade()
|
2026-04-30 20:40:51 -07:00
|
|
|
{
|
2026-05-09 21:26:16 -07:00
|
|
|
// Hybrid PCs take ONE ability mod from each parent clade — the
|
|
|
|
|
// wizard's StepClade picker records the choices, and the builder
|
|
|
|
|
// applies exactly those. Species mods don't apply for hybrids.
|
|
|
|
|
//
|
|
|
|
|
// Wolf-Folk Sire (canidae: +1 CON, +1 WIS) — sire picks CON.
|
|
|
|
|
// Rabbit-Folk Dam (leporidae: -1 STR, +2 DEX) — dam picks DEX.
|
2026-04-30 20:40:51 -07:00
|
|
|
// Base 10 across the board.
|
|
|
|
|
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
|
|
|
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
2026-05-09 21:26:16 -07:00
|
|
|
b.HybridSireChosenAbility = "CON";
|
|
|
|
|
b.HybridDamChosenAbility = "DEX";
|
|
|
|
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
|
|
|
|
Assert.True(ok);
|
|
|
|
|
Assert.Equal(10, c!.Abilities.STR); // no -1 (dam didn't pick STR), no +1 (no species mods)
|
|
|
|
|
Assert.Equal(12, c.Abilities.DEX); // dam pick: +2
|
|
|
|
|
Assert.Equal(11, c.Abilities.CON); // sire pick: +1
|
|
|
|
|
Assert.Equal(10, c.Abilities.WIS); // no +1 (sire picked CON, not WIS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void TryBuildHybrid_StacksWhenBothParentsPickSameAbility()
|
|
|
|
|
{
|
|
|
|
|
// Canidae gives +1 CON, ursidae gives +2 CON. If both parents pick
|
|
|
|
|
// CON, both bonuses apply additively.
|
|
|
|
|
var b = NewBuilderWithClassAndSkills();
|
|
|
|
|
b.HybridSireClade = _content.Clades["canidae"];
|
|
|
|
|
b.HybridSireSpecies = _content.Species["wolf"];
|
|
|
|
|
b.HybridDamClade = _content.Clades["ursidae"];
|
|
|
|
|
b.HybridDamSpecies = _content.Species["brown_bear"];
|
|
|
|
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
|
|
|
|
b.HybridSireChosenAbility = "CON";
|
|
|
|
|
b.HybridDamChosenAbility = "CON";
|
|
|
|
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
|
|
|
|
Assert.True(ok);
|
|
|
|
|
Assert.Equal(13, c!.Abilities.CON); // 10 + 1 (canid) + 2 (ursid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void TryBuildHybrid_NoBonusWhenPickIsEmpty()
|
|
|
|
|
{
|
|
|
|
|
// Defensive: empty pick = no bonus from that side. Headless tests
|
|
|
|
|
// and old saves leave the field blank; the builder should not
|
|
|
|
|
// silently fall back to the old "apply everything" rule.
|
|
|
|
|
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
|
|
|
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
|
|
|
|
// No sire or dam pick set.
|
2026-04-30 20:40:51 -07:00
|
|
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
|
|
|
|
Assert.True(ok);
|
|
|
|
|
Assert.Equal(10, c!.Abilities.STR);
|
2026-05-09 21:26:16 -07:00
|
|
|
Assert.Equal(10, c.Abilities.DEX);
|
|
|
|
|
Assert.Equal(10, c.Abilities.CON);
|
|
|
|
|
Assert.Equal(10, c.Abilities.WIS);
|
2026-04-30 20:40:51 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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")]
|
2026-05-07 22:23:47 -07:00
|
|
|
[InlineData("bovidae", "sheep", "cervidae", "elk")]
|
2026-04-30 20:40:51 -07:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
}
|