Files
TheriapolisV3/Theriapolis.Tests/Dungeons/CharacterRollLevelFlagTests.cs
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

110 lines
3.8 KiB
C#

using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — verifies the headless level-up loop the
/// <c>character-roll --level N</c> Tools flag uses. The Tools command
/// re-creates this loop in <see cref="Theriapolis.Tools.Commands.CharacterRoll"/>;
/// this test asserts the API contract works deterministically without
/// invoking the Tools assembly directly.
/// </summary>
public sealed class CharacterRollLevelFlagTests
{
private static (Character pc, IReadOnlyDictionary<string, SubclassDef> subs) BuildBase()
{
var loader = new ContentLoader(TestHelpers.DataDirectory);
var content = new ContentResolver(loader);
var b = new CharacterBuilder
{
Clade = content.Clades["canidae"],
Species = content.Species["wolf"],
ClassDef = content.Classes["fangsworn"],
Background = content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
Name = "Test",
};
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 { }
}
Assert.True(b.Validate(out _));
return (b.Build(content.Items), content.Subclasses);
}
private static Character LevelTo(int target, ulong worldSeed = 12345UL, ulong msOverride = 0UL)
{
var (pc, subs) = BuildBase();
for (int lv = 2; lv <= target; lv++)
{
ulong seed = worldSeed ^ msOverride ^ C.RNG_LEVELUP ^ (ulong)lv;
var result = LevelUpFlow.Compute(pc, lv, seed, takeAverage: true, subclasses: subs);
var choices = new LevelUpChoices
{
TakeAverageHp = true,
SubclassId = result.GrantsSubclassChoice && pc.ClassDef.SubclassIds.Length > 0
? pc.ClassDef.SubclassIds[0]
: null,
};
if (result.GrantsAsiChoice)
choices.AsiAdjustments[AbilityId.CON] = 2;
pc.ApplyLevelUp(result, choices);
}
return pc;
}
[Fact]
public void LevelN_ProducesExpectedLevelAndProficiency()
{
var pc1 = LevelTo(1);
Assert.Equal(1, pc1.Level);
Assert.Equal(2, pc1.ProficiencyBonus);
var pc5 = LevelTo(5);
Assert.Equal(5, pc5.Level);
Assert.Equal(3, pc5.ProficiencyBonus);
var pc11 = LevelTo(11);
Assert.Equal(11, pc11.Level);
Assert.Equal(4, pc11.ProficiencyBonus);
}
[Fact]
public void LevelN_PicksSubclassAtLevelThree()
{
var pc3 = LevelTo(3);
Assert.Equal(3, pc3.Level);
Assert.False(string.IsNullOrEmpty(pc3.SubclassId),
"level-3 character must have a subclass selected");
}
[Fact]
public void LevelN_AppliesAsiAtLevelFour()
{
// Auto-pilot ASI puts +2 to CON at level 4 (one of the C.ASI_LEVELS).
// Compare CON pre/post — clade + species mods are baked in by the
// builder, so absolute values vary by build choices but the
// delta is exactly 2.
var pc3 = LevelTo(3);
var pc4 = LevelTo(4);
Assert.Equal(pc3.Abilities.CON + 2, pc4.Abilities.CON);
}
[Fact]
public void LevelN_IsDeterministic()
{
var a = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
var b = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
Assert.Equal(a.Level, b.Level);
Assert.Equal(a.MaxHp, b.MaxHp);
Assert.Equal(a.SubclassId, b.SubclassId);
}
}