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>
142 lines
5.5 KiB
C#
142 lines
5.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>();
|
|
snap.LevelUpHistory = Array.Empty<LevelUpRecordState>();
|
|
|
|
var restored = CharacterCodec.Restore(snap, _content);
|
|
Assert.Equal(1, restored.Level);
|
|
Assert.Empty(restored.SubclassId);
|
|
Assert.Empty(restored.LevelUpHistory);
|
|
}
|
|
}
|