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>
241 lines
8.4 KiB
C#
241 lines
8.4 KiB
C#
using Theriapolis.Core;
|
|
using Theriapolis.Core.Data;
|
|
using Theriapolis.Core.Rules.Character;
|
|
using Theriapolis.Core.Rules.Stats;
|
|
using Xunit;
|
|
|
|
namespace Theriapolis.Tests.Rules;
|
|
|
|
/// <summary>
|
|
/// Phase 6.5 M0 — LevelUpFlow + Character.ApplyLevelUp coverage.
|
|
///
|
|
/// LevelUpFlow.Compute is pure; same (character, level, seed) → same payload.
|
|
/// ApplyLevelUp mutates in place; per-level deltas land on Level, MaxHp,
|
|
/// CurrentHp, LearnedFeatureIds, LevelUpHistory, and (when applicable)
|
|
/// SubclassId / Abilities.
|
|
/// </summary>
|
|
public sealed class LevelUpFlowTests
|
|
{
|
|
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
|
|
|
private Character MakeWolfFangsworn(int con = 10)
|
|
{
|
|
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, 12, con, 10, 13, 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 { }
|
|
}
|
|
return b.Build();
|
|
}
|
|
|
|
[Fact]
|
|
public void CanLevelUp_FalseAtZeroXp()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
Assert.Equal(1, c.Level);
|
|
Assert.Equal(0, c.Xp);
|
|
Assert.False(LevelUpFlow.CanLevelUp(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanLevelUp_TrueAt300Xp()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Xp = 300;
|
|
Assert.True(LevelUpFlow.CanLevelUp(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanLevelUp_FalseAtLevelCap()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = C.CHARACTER_LEVEL_MAX;
|
|
c.Xp = 999_999;
|
|
Assert.False(LevelUpFlow.CanLevelUp(c));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_TakeAverage_ReturnsExpectedHpGain()
|
|
{
|
|
var c = MakeWolfFangsworn(con: 14); // Wolf+canid: con+1 → 15 → +2 mod
|
|
var result = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0xCAFE, takeAverage: true);
|
|
// Fangsworn d10: average rounded up = 6. CON mod = +2. → 8
|
|
Assert.Equal(2, result.NewLevel);
|
|
Assert.Equal(8, result.HpGained);
|
|
Assert.True(result.HpWasAveraged);
|
|
Assert.False(result.GrantsAsiChoice);
|
|
Assert.False(result.GrantsSubclassChoice);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_RolledHp_IsDeterministicForSameSeed()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
var a = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
|
|
var b = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
|
|
Assert.Equal(a.HpGained, b.HpGained);
|
|
Assert.Equal(a.HpHitDieResult, b.HpHitDieResult);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_RolledHp_DifferentSeedsCanProduceDifferentResults()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
var seen = new HashSet<int>();
|
|
for (ulong s = 1; s <= 50; s++)
|
|
{
|
|
var r = LevelUpFlow.Compute(c, targetLevel: 2, seed: s, takeAverage: false);
|
|
seen.Add(r.HpHitDieResult);
|
|
}
|
|
// With 50 different seeds and a d10, we should see at least 3 distinct rolls.
|
|
Assert.True(seen.Count >= 3, $"Expected variance across 50 seeds; saw {seen.Count} distinct rolls.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_Level3_GrantsSubclassChoice()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 2;
|
|
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
|
|
Assert.True(result.GrantsSubclassChoice);
|
|
// Fangsworn has at least one subclass id in classes.json.
|
|
Assert.NotEmpty(c.ClassDef.SubclassIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_Level3_DoesNotGrantSubclassChoice_IfAlreadyPicked()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 2;
|
|
c.SubclassId = "pack_forged"; // already picked somehow
|
|
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
|
|
Assert.False(result.GrantsSubclassChoice);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_Level4_GrantsAsiChoice()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 3;
|
|
var result = LevelUpFlow.Compute(c, targetLevel: 4, seed: 0xCAFE);
|
|
Assert.True(result.GrantsAsiChoice);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_ProficiencyBonus_FollowsTable()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
Assert.Equal(2, LevelUpFlow.Compute(c, targetLevel: 2, seed: 0).NewProficiencyBonus);
|
|
Assert.Equal(3, LevelUpFlow.Compute(c, targetLevel: 5, seed: 0).NewProficiencyBonus);
|
|
Assert.Equal(4, LevelUpFlow.Compute(c, targetLevel: 9, seed: 0).NewProficiencyBonus);
|
|
Assert.Equal(5, LevelUpFlow.Compute(c, targetLevel: 13, seed: 0).NewProficiencyBonus);
|
|
Assert.Equal(6, LevelUpFlow.Compute(c, targetLevel: 17, seed: 0).NewProficiencyBonus);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compute_FeaturesUnlocked_FollowsLevelTable()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
var lv2 = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0);
|
|
// Fangsworn level 2 grants Action Surge per the level table.
|
|
Assert.NotEmpty(lv2.ClassFeaturesUnlocked);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_MutatesLevelHpAndHistory()
|
|
{
|
|
var c = MakeWolfFangsworn(con: 10); // canid +1 → CON 11 → mod 0
|
|
int hpBefore = c.MaxHp;
|
|
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
|
|
c.ApplyLevelUp(result, new LevelUpChoices { TakeAverageHp = true });
|
|
|
|
Assert.Equal(2, c.Level);
|
|
Assert.Equal(hpBefore + result.HpGained, c.MaxHp);
|
|
Assert.Equal(c.MaxHp, c.CurrentHp); // level-up restores HP
|
|
Assert.Single(c.LevelUpHistory);
|
|
Assert.Equal(2, c.LevelUpHistory[0].Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_RecordsClassFeatures()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
|
|
int beforeCount = c.LearnedFeatureIds.Count;
|
|
c.ApplyLevelUp(result, new LevelUpChoices());
|
|
Assert.Equal(beforeCount + result.ClassFeaturesUnlocked.Length, c.LearnedFeatureIds.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_WithSubclassChoice_WritesSubclassId()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 2;
|
|
c.Xp = XpTable.Threshold[3];
|
|
var result = LevelUpFlow.Compute(c, 3, 0xCAFE);
|
|
var subId = c.ClassDef.SubclassIds[0];
|
|
c.ApplyLevelUp(result, new LevelUpChoices { SubclassId = subId });
|
|
Assert.Equal(subId, c.SubclassId);
|
|
Assert.Equal(subId, c.LevelUpHistory[^1].SubclassChosen);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_WithAsi_RaisesAbilityScores()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 3;
|
|
c.Xp = XpTable.Threshold[4];
|
|
int strBefore = c.Abilities.STR;
|
|
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
|
|
c.ApplyLevelUp(result, new LevelUpChoices
|
|
{
|
|
AsiAdjustments = new() { { AbilityId.STR, 2 } },
|
|
});
|
|
Assert.Equal(strBefore + 2, c.Abilities.STR);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_AsiClampsAtAbilityCap()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
c.Level = 3;
|
|
c.SetAbilities(c.Abilities.With(AbilityId.STR, C.ABILITY_SCORE_CAP_PRE_L20));
|
|
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
|
|
c.ApplyLevelUp(result, new LevelUpChoices
|
|
{
|
|
AsiAdjustments = new() { { AbilityId.STR, 2 } },
|
|
});
|
|
Assert.Equal(C.ABILITY_SCORE_CAP_PRE_L20, c.Abilities.STR);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLevelUp_ChainedLevels_AccumulateHistoryInOrder()
|
|
{
|
|
var c = MakeWolfFangsworn();
|
|
for (int target = 2; target <= 5; target++)
|
|
{
|
|
var r = LevelUpFlow.Compute(c, target, 0xCAFE_CAFE_CAFEUL ^ (ulong)target);
|
|
var choices = new LevelUpChoices();
|
|
if (r.GrantsSubclassChoice)
|
|
choices.SubclassId = c.ClassDef.SubclassIds[0];
|
|
if (r.GrantsAsiChoice)
|
|
choices.AsiAdjustments = new() { { AbilityId.CON, 2 } };
|
|
c.ApplyLevelUp(r, choices);
|
|
}
|
|
Assert.Equal(5, c.Level);
|
|
Assert.Equal(4, c.LevelUpHistory.Count);
|
|
for (int i = 0; i < 4; i++)
|
|
Assert.Equal(i + 2, c.LevelUpHistory[i].Level);
|
|
}
|
|
}
|