using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; using Xunit; namespace Theriapolis.Tests.Rules; /// /// 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. /// 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(); 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); } }