Files
TheriapolisV3/Theriapolis.Tests/Rules/HybridCharacterTests.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

305 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}