using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Stats; using Xunit; namespace Theriapolis.Tests.Data; /// /// End-to-end content load + cross-file integrity tests for Phase 5 JSON. /// If a JSON edit breaks any of these, ContentValidate will also fail in CI. /// public sealed class ContentLoadTests { private static ContentLoader Loader() => new(TestHelpers.DataDirectory); [Fact] public void AllPhase5ContentFiles_LoadCleanly() { var loader = Loader(); var clades = loader.LoadClades(); var species = loader.LoadSpecies(clades); var classes = loader.LoadClasses(); var subs = loader.LoadSubclasses(classes); var bgs = loader.LoadBackgrounds(); var items = loader.LoadItems(); var npcs = loader.LoadNpcTemplates(items); Assert.Equal(7, clades.Length); Assert.True(species.Length >= 19, $"expected ≥19 species, got {species.Length}"); Assert.Equal(8, classes.Length); Assert.Equal(16, subs.Length); // 8 classes × 2 subclasses Assert.Equal(12, bgs.Length); Assert.True(items.Length >= 30, $"expected ≥30 items, got {items.Length}"); Assert.True(npcs.Templates.Length >= 9, $"expected ≥9 NPC templates, got {npcs.Templates.Length}"); } [Fact] public void EveryClass_HasLevel1FeaturesDefined() { var classes = Loader().LoadClasses(); foreach (var c in classes) { var lv1 = Array.Find(c.LevelTable, e => e.Level == 1); Assert.NotNull(lv1); Assert.NotEmpty(lv1!.Features); foreach (var feat in lv1.Features) Assert.True(c.FeatureDefinitions.ContainsKey(feat), $"Class '{c.Id}' level 1 references undefined feature '{feat}'"); } } [Fact] public void EveryClass_HasFullLevelTable() { var classes = Loader().LoadClasses(); foreach (var c in classes) { var levels = c.LevelTable.Select(e => e.Level).OrderBy(x => x).ToArray(); Assert.Equal(20, levels.Length); for (int lv = 1; lv <= 20; lv++) Assert.Contains(lv, levels); } } [Fact] public void EveryClass_LevelTableProficiencyBonusMatchesD20() { var classes = Loader().LoadClasses(); foreach (var c in classes) foreach (var entry in c.LevelTable) Assert.Equal(ProficiencyBonus.ForLevel(entry.Level), entry.ProficiencyBonus); } [Fact] public void EveryClass_HasTwoSubclasses() { var classes = Loader().LoadClasses(); var subs = Loader().LoadSubclasses(classes); foreach (var c in classes) { var matching = subs.Where(s => s.ClassId == c.Id).ToArray(); Assert.Equal(2, matching.Length); // Subclass ids must match what the class declares foreach (var sid in c.SubclassIds) Assert.Contains(sid, matching.Select(s => s.Id)); } } [Fact] public void EverySpecies_ReferencesARealClade() { var clades = Loader().LoadClades(); var species = Loader().LoadSpecies(clades); var cladeIds = clades.Select(c => c.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var sp in species) Assert.Contains(sp.CladeId, cladeIds); } [Theory] [InlineData("wolf", 1, 0, 1, 0, 1, 0)] [InlineData("fox", 0, 1, 1, 0, 1, 0)] [InlineData("coyote", 0, 0, 1, 0, 1, 1)] [InlineData("lion", 1, 1, 0, 0, 0, 1)] [InlineData("leopard", 0, 2, 0, 0, 0, 1)] [InlineData("housecat", 0, 1, 0, 1, 0, 1)] [InlineData("ferret", 0, 1, 0, 1, 0, 1)] [InlineData("badger", 0, 1, 1, 1, 0, 0)] [InlineData("wolverine", 1, 1, 0, 1, 0, 0)] [InlineData("brown_bear", 1,-1, 2, 0, 0, 0)] [InlineData("polar_bear", 0,-1, 2, 0, 1, 0)] [InlineData("elk", 1, 1, 0, 0, 1, 0)] [InlineData("deer", 0, 2, 0, 0, 1, 0)] [InlineData("moose", 0, 1, 1, 0, 1, 0)] [InlineData("rabbit", -1, 2, 0, 0, 1, 0)] [InlineData("hare", -1, 2, 1, 0, 0, 0)] [InlineData("bull", 2, 0, 1, 0, 0, 0)] [InlineData("sheep", 1, 0, 1, 0, 1, 0)] [InlineData("goat", 1, 0, 1, 0, 1, 0)] [InlineData("bison", 1, 0, 2, 0, 0, 0)] public void Clade_Plus_Species_AbilityMods_MatchQuickRefTable( string speciesId, int str, int dex, int con, int @int, int wis, int cha) { var loader = Loader(); var clades = loader.LoadClades(); var species = loader.LoadSpecies(clades); var sp = species.Single(s => s.Id == speciesId); var cl = clades.Single(c => c.Id == sp.CladeId); int Sum(string ability) => (cl.AbilityMods.TryGetValue(ability, out var c) ? c : 0) + (sp.AbilityMods.TryGetValue(ability, out var s) ? s : 0); Assert.Equal(str, Sum("STR")); Assert.Equal(dex, Sum("DEX")); Assert.Equal(con, Sum("CON")); Assert.Equal(@int, Sum("INT")); Assert.Equal(wis, Sum("WIS")); Assert.Equal(cha, Sum("CHA")); } [Fact] public void EveryWeapon_HasDamageAndType() { var items = Loader().LoadItems(); foreach (var i in items.Where(i => i.Kind == "weapon")) { Assert.False(string.IsNullOrWhiteSpace(i.Damage), $"weapon '{i.Id}' missing damage"); Assert.False(string.IsNullOrWhiteSpace(i.DamageType), $"weapon '{i.Id}' missing damage_type"); } } [Fact] public void EveryArmor_HasPositiveAcBase() { var items = Loader().LoadItems(); foreach (var i in items.Where(i => i.Kind == "armor")) Assert.True(i.AcBase > 0, $"armor '{i.Id}' has non-positive ac_base"); } [Fact] public void NpcZoneTable_HasOneEntryPerZone() { var items = Loader().LoadItems(); var npcs = Loader().LoadNpcTemplates(items); int expected = Theriapolis.Core.C.DANGER_ZONE_MAX - Theriapolis.Core.C.DANGER_ZONE_MIN + 1; foreach (var (kind, byZone) in npcs.SpawnKindToTemplateByZone) Assert.Equal(expected, byZone.Length); } [Fact] public void NpcZoneTable_AllReferencedTemplatesExist() { var items = Loader().LoadItems(); var npcs = Loader().LoadNpcTemplates(items); var ids = npcs.Templates.Select(t => t.Id).ToHashSet(); foreach (var (_, byZone) in npcs.SpawnKindToTemplateByZone) foreach (var tid in byZone) Assert.Contains(tid, ids); } [Fact] public void EveryNpcTemplate_HasPositiveHpAndAc() { var items = Loader().LoadItems(); var npcs = Loader().LoadNpcTemplates(items); foreach (var t in npcs.Templates) { Assert.True(t.Hp > 0, $"NPC '{t.Id}' has non-positive HP"); Assert.True(t.Ac > 0, $"NPC '{t.Id}' has non-positive AC"); } } }