b451f83174
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>
164 lines
6.3 KiB
C#
164 lines
6.3 KiB
C#
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;
|
||
}
|
||
}
|