using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Persistence; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; using Xunit; namespace Theriapolis.Tests.Persistence; /// /// Phase 6.5 M0 — level-up history + subclass + learned-features round-trip. /// Save a character at level 4 (with subclass + ASI history); load; assert /// every per-level delta survives. /// public sealed class LevelUpRoundTripTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); private Character MakeWolfFangsworn() { 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, 13, 10, 13, 8), Name = "Tester", }; 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(); } private void LevelTo(Character c, int target) { while (c.Level < target) { int next = c.Level + 1; ulong seed = 0xCAFE_F00D_CAFE_F00DUL ^ (ulong)next; var r = LevelUpFlow.Compute(c, next, seed, takeAverage: true); var ch = new LevelUpChoices { TakeAverageHp = true }; if (r.GrantsSubclassChoice && c.ClassDef.SubclassIds.Length > 0) ch.SubclassId = c.ClassDef.SubclassIds[0]; if (r.GrantsAsiChoice) ch.AsiAdjustments = new() { { AbilityId.STR, 2 } }; c.ApplyLevelUp(r, ch); } } [Fact] public void Character_AtLevel4_RoundTripsThroughCharacterCodec() { var c = MakeWolfFangsworn(); c.Xp = 6_500; // beyond level 4 threshold LevelTo(c, target: 4); Assert.Equal(4, c.Level); Assert.NotEmpty(c.SubclassId); // L3 picker fired Assert.NotEmpty(c.LearnedFeatureIds); Assert.Equal(3, c.LevelUpHistory.Count); // L2, L3, L4 var snap = CharacterCodec.Capture(c); var restored = CharacterCodec.Restore(snap, _content); Assert.Equal(4, restored.Level); Assert.Equal(c.SubclassId, restored.SubclassId); Assert.Equal(c.MaxHp, restored.MaxHp); Assert.Equal(c.LearnedFeatureIds.Count, restored.LearnedFeatureIds.Count); Assert.Equal(c.LearnedFeatureIds, restored.LearnedFeatureIds); Assert.Equal(c.LevelUpHistory.Count, restored.LevelUpHistory.Count); for (int i = 0; i < c.LevelUpHistory.Count; i++) { var a = c.LevelUpHistory[i]; var b = restored.LevelUpHistory[i]; Assert.Equal(a.Level, b.Level); Assert.Equal(a.HpGained, b.HpGained); Assert.Equal(a.HpWasAveraged, b.HpWasAveraged); Assert.Equal(a.HpHitDieResult, b.HpHitDieResult); Assert.Equal(a.SubclassChosen, b.SubclassChosen); Assert.Equal(a.FeaturesUnlocked, b.FeaturesUnlocked); Assert.Equal(a.AsiAdjustmentsKeys, b.AsiAdjustmentsKeys); Assert.Equal(a.AsiAdjustmentsValues, b.AsiAdjustmentsValues); } // ASI raised STR — level-4 history entry should record +2 STR. var lv4 = c.LevelUpHistory[^1]; Assert.Equal(4, lv4.Level); Assert.Single(lv4.AsiAdjustmentsValues); Assert.Equal(2, lv4.AsiAdjustmentsValues[0]); // And the ability is preserved across the round-trip. Assert.Equal(c.Abilities.STR, restored.Abilities.STR); } [Fact] public void Character_AtLevel4_RoundTripsThroughBinarySaveCodec() { var c = MakeWolfFangsworn(); c.Xp = 6_500; LevelTo(c, target: 4); var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED" }; var body = new SaveBody { PlayerCharacter = CharacterCodec.Capture(c) }; body.Player.Id = 1; body.Player.Name = "Tester"; var bytes = SaveCodec.Serialize(header, body); var (h2, body2) = SaveCodec.Deserialize(bytes); Assert.Equal(header.Version, h2.Version); Assert.NotNull(body2.PlayerCharacter); Assert.Equal(4, body2.PlayerCharacter!.Level); Assert.NotEmpty(body2.PlayerCharacter.SubclassId); Assert.Equal(3, body2.PlayerCharacter.LevelUpHistory.Length); Assert.Equal(c.LearnedFeatureIds.Count, body2.PlayerCharacter.LearnedFeatureIds.Length); } [Fact] public void V6Save_WithoutLevelUpFields_LoadsAsLevel1Character() { // Simulate a v6 save by writing a character without the v7 trailing // fields. Easiest path: hand-construct a minimal PlayerCharacterState // (the codec's EOS-check pattern handles missing trailing data on read). var c = MakeWolfFangsworn(); var snap = CharacterCodec.Capture(c); // Force the v7 fields to defaults to simulate a v6 save. snap.SubclassId = ""; snap.LearnedFeatureIds = Array.Empty(); snap.LevelUpHistory = Array.Empty(); var restored = CharacterCodec.Restore(snap, _content); Assert.Equal(1, restored.Level); Assert.Empty(restored.SubclassId); Assert.Empty(restored.LevelUpHistory); } }