Files
TheriapolisV3/Theriapolis.Tests/Rules/HybridCharacterTests.cs
T
Christopher Wiebe 39117a09ed M6.17: Variant content + Sheep/Goat split + calling lore + uniform card layout
Species variants populated against the M6.13 schema:
- Lion-Folk sex axis: Mane Guard (male) / Huntress Reflexes (female,
  +5 ft speed + advantage on initiative).
- Elk-Folk sex axis: Antler Combat with 10 ft reach when full rack
  (male, retains seasonal Antler Drag) / Kick (female, prone on crit).
  Base traits restored to doc canon: Herd Coordination (Help → +3) +
  Endurance Runner (40 ft + advantage CON vs forced march); base speed
  bumped 30 → 40; new base detriment Herd Instinct.

Ram-Folk replaced with separate Sheep-Folk + Goat-Folk species rather
than a lineage-axis variant on a single Ram entry. Bovidae now has 4
species. The lineage-axis toggle UI in StepSpecies BuildCard rolled
back; the schema stays for sex-axis (Lion/Elk) which auto-resolves.
ContentLoadTests + HybridCharacterTests updated; Size.cs comment too.

Calling lore: ClassDef gains Description; classes.json populated for
all 8 callings with the doc's italic blockquote + paragraph profile.
StepClass surfaces the description on the card.

Card layout uniformity: StepClass / StepSubclass / StepBackground all
switched to single-column ExpandFill grids (matching StepClade /
StepSpecies). Each card now spans the wizard's content width so the
description and feature chips have room to breathe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:23:47 -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", "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);
}
}