Files
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

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);
}
}