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,160 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — clade-responsive movement multiplier tests. Verifies the
/// table from Phase 7 plan §5.4 and that hybrid PCs use the dominant-
/// lineage's presenting size for the lookup.
/// </summary>
public sealed class ClademorphicMovementTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Plan §5.4 table values ────────────────────────────────────────────
[Fact]
public void LargePc_InMustelidTunnel_PaysHeavyMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_HEAVY,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "mustelid"));
}
[Fact]
public void MediumLargePc_InMustelidTunnel_PaysMediumMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.MediumLarge, "mustelid"));
}
[Fact]
public void MediumPc_InMustelidTunnel_PaysLightMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Medium, "mustelid"));
}
[Fact]
public void SmallPc_InMustelidTunnel_NoPenalty()
{
Assert.Equal(1.0f,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "mustelid"));
}
[Fact]
public void SmallPc_InUrsidHall_PaysExposedMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "ursid"));
}
[Fact]
public void LargePc_InCervidHall_PaysAntlerClearancePenalty()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "cervid"));
}
[Fact]
public void AnyPc_InImperiumOrNoneRoom_NoPenalty()
{
foreach (var size in new[] { SizeCategory.Small, SizeCategory.Medium, SizeCategory.MediumLarge, SizeCategory.Large })
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "imperium"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "none"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, ""));
}
}
[Fact]
public void UnknownBuiltBy_NoPenalty()
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "garbage"));
}
// ── Hybrid PC presenting-size lookup ─────────────────────────────────
[Fact]
public void HybridPc_UsesPresentingClade_ForSizeLookup()
{
// Build a Wolf-Folk × Hare-Folk hybrid presenting as Hare-Folk
// (Dam dominant). EffectiveSize should be the presenting species' size.
var pc = BuildHybrid(
sireClade: "canidae", sireSpecies: "wolf",
damClade: "leporidae", damSpecies: "hare",
dominant: ParentLineage.Dam);
// The hybrid build path picked Hare-Folk as the presenting species,
// so EffectiveSize should match Character.Size (which the builder
// already set to Hare-Folk's size category).
Assert.Equal(pc.Size, ClademorphicMovement.EffectiveSize(pc));
// Whichever species the builder chose, the multiplier should match
// its size's lookup in a Mustelid tunnel.
var expected = ClademorphicMovement.GetCostMultiplier(pc.Size, "mustelid");
Assert.Equal(expected, ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
[Fact]
public void NonHybridPc_UsesOwnSize_ForLookup()
{
var pc = BuildPurebred("canidae", "wolf"); // wolf = MediumLarge
Assert.Equal(SizeCategory.MediumLarge, pc.Size);
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
// ── Helpers ──────────────────────────────────────────────────────────
private Character BuildPurebred(string cladeId, string speciesId)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
};
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);
}
private Character BuildHybrid(
string sireClade, string sireSpecies,
string damClade, string damSpecies,
ParentLineage dominant)
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades[sireClade],
HybridSireSpecies = _content.Species[sireSpecies],
HybridDamClade = _content.Clades[damClade],
HybridDamSpecies = _content.Species[damSpecies],
HybridDominantParent = dominant,
};
// Chosen skills don't matter for size-of-character — pick first N.
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 { }
}
Assert.True(b.TryBuildHybrid(_content.Items, out var character, out _));
return character!;
}
}