Files
TheriapolisV3/Theriapolis.Tests/Rules/CharacterBuilderTests.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

164 lines
6.3 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 Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// CharacterBuilder smoke + integration: every (clade × species) pair,
/// when assigned a representative class, produces a valid level-1 character
/// with sane HP and ability totals.
/// </summary>
public sealed class CharacterBuilderTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Build_DefaultsProduceValidCharacter()
{
var c = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised").Build();
Assert.Equal("canidae", c.Clade.Id);
Assert.Equal("wolf", c.Species.Id);
Assert.Equal("fangsworn", c.ClassDef.Id);
Assert.Equal(1, c.Level);
Assert.Equal(0, c.Xp);
Assert.True(c.MaxHp > 0);
Assert.Equal(c.MaxHp, c.CurrentHp);
Assert.True(c.SkillProficiencies.Count >= c.ClassDef.SkillsChoose);
}
[Fact]
public void Build_AppliesCladeAndSpeciesAbilityMods()
{
// Wolf-Folk: STR+1 (species), CON+1 WIS+1 (canid clade)
// Standard Array assigned in class priority — we use a known base for
// the test instead of relying on auto-assignment.
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
var c = b.Build();
Assert.Equal(11, c.Abilities.STR); // 10 + species
Assert.Equal(10, c.Abilities.DEX);
Assert.Equal(11, c.Abilities.CON); // 10 + clade
Assert.Equal(10, c.Abilities.INT);
Assert.Equal(11, c.Abilities.WIS); // 10 + clade
Assert.Equal(10, c.Abilities.CHA);
}
[Fact]
public void Build_HpUsesHitDiePlusConModifier()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.BaseAbilities = new AbilityScores(10, 10, 14, 10, 10, 10); // CON 14 → +2 → +1 after canid (15→+2)
var c = b.Build();
// Wolf-Folk has no CON mod from species, canid +1 → final CON = 15 → +2.
// Fangsworn d10 + 2 = 12.
Assert.Equal(12, c.MaxHp);
}
[Fact]
public void Validate_RejectsSpeciesNotInChosenClade()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.Species = _content.Species["lion"]; // felid species under canid clade
bool ok = b.Validate(out var err);
Assert.False(ok);
Assert.Contains("clade", err.ToLowerInvariant());
}
[Fact]
public void Validate_RequiresExactSkillCount()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.ChosenClassSkills.Clear();
Assert.False(b.Validate(out _));
}
[Fact]
public void Validate_RejectsSkillNotOfferedByClass()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.ChosenClassSkills.Clear();
b.ChosenClassSkills.Add(SkillId.Athletics);
b.ChosenClassSkills.Add(SkillId.Arcana); // Fangsworn does not offer Arcana
Assert.False(b.Validate(out var err));
Assert.Contains("arcana", err.ToLowerInvariant());
}
[Theory]
[InlineData("canidae", "wolf", "fangsworn", "pack_raised")]
[InlineData("felidae", "lion", "shadow_pelt", "borderland_stray")]
[InlineData("ursidae", "brown_bear", "feral", "coliseum_survivor")]
[InlineData("cervidae", "elk", "covenant_keeper", "covenant_enforcer")]
[InlineData("bovidae", "bull", "bulwark", "herd_city_born")]
[InlineData("leporidae", "rabbit", "muzzle_speaker", "warren_runner")]
[InlineData("mustelidae","badger", "claw_wright", "borderland_stray")]
[InlineData("felidae", "housecat", "scent_broker", "scent_suppressed")]
public void Build_AllRepresentativeCombosValid(
string cladeId, string speciesId, string classId, string bgId)
{
var c = MinimalBuilder(cladeId, speciesId, classId, bgId).Build();
Assert.True(c.MaxHp > 0);
Assert.True(c.IsAlive);
}
[Fact]
public void RollAbilityScores_IsDeterministicGivenSameInputs()
{
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
Assert.Equal(a.STR, b.STR);
Assert.Equal(a.DEX, b.DEX);
Assert.Equal(a.CON, b.CON);
Assert.Equal(a.INT, b.INT);
Assert.Equal(a.WIS, b.WIS);
Assert.Equal(a.CHA, b.CHA);
}
[Fact]
public void RollAbilityScores_DifferentMsProducesDifferentResults()
{
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 5678);
// At least one of six should differ across plausibly-different rolls.
Assert.True(
a.STR != b.STR || a.DEX != b.DEX || a.CON != b.CON ||
a.INT != b.INT || a.WIS != b.WIS || a.CHA != b.CHA);
}
[Fact]
public void Roll4d6DropLowest_ResultIs3to18()
{
var rng = new SeededRng(0xDEADBEEFUL);
for (int i = 0; i < 1000; i++)
{
int v = CharacterBuilder.Roll4d6DropLowest(rng);
Assert.InRange(v, 3, 18);
}
}
// ── Helpers ──────────────────────────────────────────────────────────
private CharacterBuilder MinimalBuilder(string cladeId, string speciesId, string classId, string bgId)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes[classId],
Background = _content.Backgrounds[bgId],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Test",
};
// Auto-pick first N skill options
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;
}
}