Files
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

194 lines
7.0 KiB
C#
Raw Permalink 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.Stats;
using Xunit;
namespace Theriapolis.Tests.Data;
/// <summary>
/// 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.
/// </summary>
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");
}
}
}