using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; using Xunit; namespace Theriapolis.Tests.Rules; /// /// CharacterBuilder smoke + integration: every (clade × species) pair, /// when assigned a representative class, produces a valid level-1 character /// with sane HP and ability totals. /// 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; } }