using System; using System.Linq; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Player; using Xunit; namespace AcDream.App.Tests.UI.Layout; /// /// Tests for — the extracted character-sheet /// assembly + raise flow (formerly private methods inside GameWindow). Covers the /// XP-curve math against a known synthetic ExperienceTable and the single-owner /// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire /// CharacterChanged). /// public sealed class CharacterSheetProviderTests { private const uint PlayerGuid = 0x50000001u; /// Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250. private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new() { Levels = new ulong[] { 0, 100, 250, 450 }, Attributes = new uint[] { 0, 10, 30, 60, 100 }, Vitals = new uint[] { 0, 4, 12, 24 }, TrainedSkills = new uint[] { 0, 5, 15, 30 }, SpecializedSkills = new uint[] { 0, 8, 24, 48 }, }; private sealed class Harness { public ClientObjectTable Table { get; } = new(); public LocalPlayerState Player { get; } = new(); public CharacterSheetProvider Provider { get; } public (uint statId, ulong cost)? SentAttribute; public (uint skillId, uint credits)? SentTrain; public bool CanSend = true; public Harness() { Provider = new CharacterSheetProvider( Table, Player, playerGuid: () => PlayerGuid, activeToonName: () => "default", fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 }, canSendRaise: () => CanSend, sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost), sendRaiseVital: (_, _) => { }, sendRaiseSkill: (_, _) => { }, sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits)) { ExperienceTable = MakeXpTable(), }; } /// Put the player's ClientObject in the table with live sheet properties. public ClientObject AddPlayerObject(long unassignedXp = 1000L) { var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; player.Properties.Ints[0x19u] = 1; // level player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band player.Properties.Int64s[2u] = unassignedXp; Table.AddOrUpdate(player); return player; } } [Fact] public void BuildSheet_NoLiveData_UsesFallbackSheet() { var h = new Harness(); var sheet = h.Provider.BuildSheet(); Assert.Equal(-1, sheet.Level); // fallback marker Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player" } [Fact] public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts() { var h = new Harness(); h.AddPlayerObject(unassignedXp: 777L); h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength var sheet = h.Provider.BuildSheet(); Assert.Equal("Testy", sheet.Name); // live object name beats "Player" Assert.Equal(1, sheet.Level); Assert.Equal(150L, sheet.TotalXp); Assert.Equal(777L, sheet.UnassignedXp); // Level band 100..250, at 150: 100 XP to next, 1/3 through the band. Assert.Equal(100L, sheet.XpToNextLevel); Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4); Assert.Equal(11, sheet.Strength); // ranks + start // Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10. Assert.Equal(20L, sheet.AttributeRaiseCosts[0]); Assert.Equal(90L, sheet.AttributeRaise10Costs[0]); } [Fact] public void BuildSheet_Skills_MapsAdvancementAndCurveCosts() { var h = new Harness(); h.AddPlayerObject(); h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u, init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u, init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded var sheet = h.Provider.BuildSheet(); var skill = Assert.Single(sheet.Skills); Assert.Equal(6u, skill.Id); Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass); // TrainedSkills curve: x1 = 15 − 5; x10 clamps at index 3: 30 − 5. Assert.Equal(10L, skill.RaiseCost); Assert.Equal(25L, skill.Raise10Cost); } [Fact] public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents() { var h = new Harness(); h.AddPlayerObject(unassignedXp: 1000L); h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); int tableUpdates = 0; h.Table.ObjectUpdated += _ => tableUpdates++; h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); Assert.Equal((1u, 20ul), h.SentAttribute); var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength); Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited Assert.True(tableUpdates >= 1); // via the eventful API } [Fact] public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse() { var h = new Harness(); h.AddPlayerObject(unassignedXp: 1000L); h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); h.CanSend = false; h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); Assert.Null(h.SentAttribute); Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks); Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); } [Fact] public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty() { var h = new Harness(); var player = h.AddPlayerObject(); player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u, init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1)); Assert.Equal((6u, 4u), h.SentTrain); Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited } [Fact] public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent() { var h = new Harness(); // note: nothing added to the table var props = new PropertyBundle(); props.Int64s[2u] = 500L; h.Player.OnProperties(props); h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); int changed = 0; h.Player.CharacterChanged += () => changed++; h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1)); Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side Assert.True(changed >= 1); // and CharacterChanged fired } }