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>
110 lines
3.8 KiB
C#
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);
|
|
}
|
|
}
|