From d6e8b603032cb7bd112406e0519be9b9fa443c8f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 10:16:27 +0200 Subject: [PATCH] fix(movement): invalidate burden on enchantment changes --- docs/ISSUES.md | 37 ++++++++++ docs/plans/2026-04-11-roadmap.md | 6 +- .../2026-07-29-physics-parity-campaign.md | 8 +++ ...-07-30-stat-coupled-movement-pseudocode.md | 17 +++-- .../project_movement_collision_conformance.md | 9 +++ .../InteractionRetainedUiComposition.cs | 11 ++- .../UI/Layout/IndicatorBarController.cs | 9 ++- .../UI/Layout/InventoryController.cs | 15 +++- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- .../Session/LiveSessionEventRouter.cs | 8 ++- .../UI/Layout/IndicatorBarControllerTests.cs | 23 ++++++- .../UI/Layout/InventoryControllerTests.cs | 41 ++++++++++- .../Session/LiveSessionEventRouterTests.cs | 68 +++++++++++++++++++ 13 files changed, 231 insertions(+), 25 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 96b43e8f..3dc8f9a3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -98,6 +98,43 @@ Copy this block when adding a new issue: --- +## #272 — Strength enchantments do not invalidate burden + +**Status:** DONE — 2026-07-31 (implementation, automated gates, and user live +buff/death gate) +**Severity:** HIGH (movement state and retained HUD disagree with retail) +**Filed:** 2026-07-31 +**Component:** player qualities / enchantments / burden + +**Symptom:** while overburdened, casting a Strength spell did not reduce the +burden state until the base Strength attribute changed. Dying purged the +Strength spell but did not restore the overburdened state. + +**Root cause:** `LiveSessionEventRouter.RecomputeBurden` read raw +`AttributeValue.Current` and subscribed only to base Strength/object-table +changes. The indicator bar and inventory meter had the same raw-Strength +composition, and the inventory meter did not observe enchantment changes. +Retail `CACQualities::InqLoad @ 0x0058F130` calls +`CACQualities::InqAttribute @ 0x00591A00`, which applies +`CACQualities::EnchantAttribute @ 0x00594570`; every load query therefore uses +effective Strength. + +**Fix:** all three consumers now read +`LocalPlayerState.GetEffectiveAttribute(Strength)`. The Runtime burden owner, +indicator bar, and inventory meter subscribe to the canonical +`Spellbook.EnchantmentsChanged` edge, covering add, remove, expiration, +dispel, and death purge through one path. Regression tests pin both +buff-to-unburdened and purge-to-overburdened transitions without any base +attribute update. + +**Acceptance:** overload a character, cast a Strength spell, and observe the +burden icon/meter plus movement update immediately. Die while the spell is +active and observe the spell purge restore the overburdened icon/meter and +movement immediately. **Passed live 2026-07-31:** the user confirmed the +burden state now updates correctly. + +--- + ## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down **Status:** DONE — 2026-07-31 (implementation + user live gate) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4aa27c21..39561d28 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -53,7 +53,11 @@ TS-8 are retired by focused and end-to-end packet tests. #269's capture-driven slope-slide residual is also closed and user-accepted: `CTransition::validate_transition` now performs retail's non-OK-only remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`. -Campaign P now continues with the unfinished live matrix rows. +The matrix then exposed #272: burden was invalidated by base Strength but not +by Strength enchantment add/purge. Runtime movement plus both retained burden +surfaces now use effective Strength and the canonical enchantment-change edge; +automated gates pass and the connected buff/death gate was user-accepted on +2026-07-31. Campaign P then continues with the unfinished live matrix rows. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index d06b0696..2a204afa 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -400,6 +400,14 @@ root-caused, retail-ported, and user-accepted in the same session: and the complete Release suite passes 10,062 tests / 5 skips. The user accepted repeated uphill runs while pressing into the stair sides. Evidence: `docs/research/2026-07-31-271-stair-side-slide-capture.md`. +- **#272 complete and user-accepted 2026-07-31** — + `CACQualities::InqLoad` consumes enchantment-adjusted Strength through + `InqAttribute`, but Runtime movement and both retained burden displays read + raw Strength and did not share the enchantment invalidation edge. They now + consume `GetEffectiveAttribute(Strength)` and + `Spellbook.EnchantmentsChanged`, so buff, dispel, expiration, and death + purge recompute the same burden state immediately. Focused and full + Runtime/App tests pass. Matrix rows accepted so far: speed parity, roof slide, downhill bounce, flat pop, uphill landing, and #269's slope-stop feel diff --git a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md index 6d8ff3ee..8493e940 100644 --- a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md +++ b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md @@ -54,11 +54,16 @@ InqLoad(this, &loadOut): return 1 // always succeeds for CACQualities (has vtable) ``` -This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()` -/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop -0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the -wire value is absent) — already ported, already correct, already tested via -the UI. **`AcDream.Core.Items.BurdenMath` +The property/capacity shape matches acdream's +`IndicatorBarController.UpdateBurden()` / +`InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6 +aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire +value is absent). A 2026-07-31 connected gate exposed one omitted retail +detail: `InqAttribute` returns the enchantment-adjusted attribute, while all +three acdream burden consumers still read raw `AttributeValue.Current`. +Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)` +and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge. +**`AcDream.Core.Items.BurdenMath` (`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather @@ -317,8 +322,10 @@ Runtime (AcDream.Runtime, presentation-free): - onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...) - NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved, ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength) + + Spellbook.EnchantmentsChanged -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal, SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden) + using effective/enchantment-adjusted Strength -> character.Character.MovementSkills.UpdateBurden(ratio) - NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina) -> character.Character.MovementSkills.UpdateStamina(current) diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 99bdbcf8..65cdbffe 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -99,3 +99,12 @@ InputDispatcher / PlayerMovementController 5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31. See `docs/research/2026-07-31-271-stair-side-slide-capture.md`. +- 2026-07-31: #272 burden invalidation. Retail `InqLoad @ 0x0058F130` + obtains Strength through `InqAttribute @ 0x00591A00`, so carry capacity + always uses the enchantment-adjusted value. Runtime movement, the indicator + bar, and the inventory meter must all consume + `LocalPlayerState.GetEffectiveAttribute(Strength)` and react to the same + `Spellbook.EnchantmentsChanged` event. That one event covers spell add, + remove, expiration, dispel, and death purge. Never refresh this through a + UI-only workaround or wait for a base-attribute packet. The connected + buff/death gate was user-accepted on 2026-07-31. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 535121ef..4d084b7f 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -614,9 +614,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Character.Spellbook, d.Inventory.Objects, () => d.PlayerIdentity.ServerGuid, - () => d.Character.LocalPlayer.GetAttribute( - LocalPlayerState.AttributeKind.Strength) - is { } strength ? (int?)strength.Current : null, + () => d.Character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength), () => late.Session.LinkStatus, d.ClientTime, () => late.Session.CurrentSession?.RequestLinkStatusPing(), @@ -656,9 +655,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory () => d.PlayerIdentity.ServerGuid, iconComposer.GetIcon, iconComposer.GetDragIcon, - () => d.Character.LocalPlayer.GetAttribute( - LocalPlayerState.AttributeKind.Strength) - is { } strength ? (int?)strength.Current : null, + () => d.Character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength), + d.Character.Spellbook, guid => late.Session.CurrentSession?.SendUse(guid), (item, container, placement) => late.Session.CurrentSession?.SendPutItemInContainer( diff --git a/src/AcDream.App/UI/Layout/IndicatorBarController.cs b/src/AcDream.App/UI/Layout/IndicatorBarController.cs index 60e6b741..6f4becf7 100644 --- a/src/AcDream.App/UI/Layout/IndicatorBarController.cs +++ b/src/AcDream.App/UI/Layout/IndicatorBarController.cs @@ -91,7 +91,7 @@ public sealed class IndicatorBarController : IRetainedPanelController _miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame); _endCharacterSession.OnClick = bindings.RequestEndCharacterSession; - bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; + bindings.Spellbook.EnchantmentsChanged += OnEnchantmentsChanged; bindings.Objects.ObjectAdded += OnObjectChanged; bindings.Objects.ObjectUpdated += OnObjectChanged; bindings.Objects.ObjectRemoved += OnObjectChanged; @@ -229,6 +229,11 @@ public sealed class IndicatorBarController : IRetainedPanelController private void OnObjectMoved(ClientObjectMove _) => UpdateBurden(); private void OnContainerContentsReplaced(uint _) => UpdateBurden(); private void OnObjectsCleared() => UpdateBurden(); + private void OnEnchantmentsChanged() + { + UpdateEnchantments(); + UpdateBurden(); + } private void UpdateBurden() { @@ -253,7 +258,7 @@ public sealed class IndicatorBarController : IRetainedPanelController { if (_disposed) return; _disposed = true; - _bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments; + _bindings.Spellbook.EnchantmentsChanged -= OnEnchantmentsChanged; _bindings.Objects.ObjectAdded -= OnObjectChanged; _bindings.Objects.ObjectUpdated -= OnObjectChanged; _bindings.Objects.ObjectRemoved -= OnObjectChanged; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 4030a4b1..ec4f5e1b 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -3,6 +3,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Selection; +using AcDream.Core.Spells; namespace AcDream.App.UI.Layout; @@ -43,6 +44,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private readonly Func _iconIds; private readonly Func? _dragIconIds; private readonly Func _strength; + private readonly Spellbook? _burdenSpellbook; private readonly Func? _ownerName; private readonly UiItemList? _contentsGrid; @@ -96,13 +98,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Action? notifyMergeAttempt, ItemInteractionController? itemInteraction, Action? onClose, - StackSplitQuantityState? stackSplitQuantity) + StackSplitQuantityState? stackSplitQuantity, + Spellbook? burdenSpellbook) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _dragIconIds = dragIconIds; _strength = strength; + _burdenSpellbook = burdenSpellbook; _ownerName = ownerName; _sendUse = sendUse; _sendPutItemInContainer = sendPutItemInContainer; @@ -195,6 +199,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated += OnObjectChanged; _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; + if (_burdenSpellbook is not null) + _burdenSpellbook.EnchantmentsChanged += RefreshBurden; if (_itemInteraction is not null) { _itemInteraction.StateChanged += OnInteractionStateChanged; @@ -244,14 +250,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo ItemInteractionController? itemInteraction = null, Action? onClose = null, StackSplitQuantityState? stackSplitQuantity = null, - Func? dragIconIds = null) + Func? dragIconIds = null, + Spellbook? burdenSpellbook = null) => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection, ownerName, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, sendUse, sendPutItemInContainer, sendStackableSplitToContainer, sendStackableMerge, notifyMergeAttempt, itemInteraction, - onClose, stackSplitQuantity); + onClose, stackSplitQuantity, burdenSpellbook); private void OnObjectChanged(ClientObject o) { @@ -944,6 +951,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; + if (_burdenSpellbook is not null) + _burdenSpellbook.EnchantmentsChanged -= RefreshBurden; if (_contentsGrid is not null) { _contentsGrid.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index cec6f563..d8a45095 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -122,6 +122,7 @@ public sealed record InventoryRuntimeBindings( Func ResolveIcon, Func ResolveDragIcon, Func Strength, + Spellbook Spellbook, Action? SendUse, Action? SendPutItemInContainer, Action? SendStackableSplitToContainer, @@ -1816,7 +1817,8 @@ public sealed class RetailUiRuntime : IDisposable notifyMergeAttempt, b.ItemInteraction, () => CloseWindow(WindowNames.Inventory), StackSplitQuantity, - b.ResolveDragIcon); + b.ResolveDragIcon, + b.Spellbook); InventoryPanelController = inventory; PaperdollController paperdoll = PaperdollController.Bind( layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 704fa486..9098d5e3 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -229,6 +229,10 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting if (kind == LocalPlayerState.AttributeKind.Strength) RecomputeBurden(inventory, character); }); + SubscribeParameterless( + h => character.Character.Spellbook.EnchantmentsChanged += h, + h => character.Character.Spellbook.EnchantmentsChanged -= h, + () => RecomputeBurden(inventory, character)); // Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's // stamina==0 effective-skill-zeroing gate (pseudocode doc §5). @@ -370,8 +374,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); - int strength = (int)(character.Character.LocalPlayer - .GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u); + int strength = character.Character.LocalPlayer + .GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0; int aug = playerObject?.Properties.GetInt( (uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0; int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug); diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs index 8e5c01f9..84fa8f60 100644 --- a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs @@ -89,6 +89,25 @@ public sealed class IndicatorBarControllerTests Assert.Equal(expectedState, button.ActiveRetailStateId); } + [Fact] + public void EnchantmentChange_ReevaluatesBurdenFromEffectiveStrength() + { + var h = CreateHarness(strength: 10); + using IndicatorBarController controller = h.Controller; + h.Objects.UpdateIntProperty(Player, 5u, 1600); + UiButton button = h.Button(IndicatorBarController.BurdenButtonId); + Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId); + + h.Strength = 20; + h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + 42u, 1u, 60d, Player, Bucket: 1u)); + Assert.Equal(IndicatorBarController.UnencumberedState, button.ActiveRetailStateId); + + h.Strength = 10; + h.Spellbook.OnPurgeAll(); + Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId); + } + [Fact] public void Burden_ClickOpensCharacterInformationPanel() { @@ -205,7 +224,7 @@ public sealed class IndicatorBarControllerTests spellbook, objects, () => Player, - () => strength, + () => harness.Strength, () => harness.LinkStatus, () => harness.Time, harness.ToggledPanels.Add, @@ -222,7 +241,7 @@ public sealed class IndicatorBarControllerTests public ImportedLayout Layout { get; } = layout; public Spellbook Spellbook { get; } = spellbook; public ClientObjectTable Objects { get; } = objects; - public int Strength { get; } = strength; + public int Strength { get; set; } = strength; public List ToggledPanels { get; } = []; public double Time { get; set; } public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 9e7dcc3f..fd61d255 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -3,6 +3,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Selection; +using AcDream.Core.Spells; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -63,10 +64,12 @@ public class InventoryControllerTests Action? onClose = null, SelectionState? selection = null, StackSplitQuantityState? stackSplitQuantity = null, - ItemInteractionController? itemInteraction = null) + ItemInteractionController? itemInteraction = null, + Func? strengthProvider = null, + Spellbook? burdenSpellbook = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, - strength: () => strength, datFont: null, + strength: strengthProvider ?? (() => strength), datFont: null, ownerName: ownerName is null ? null : () => ownerName, sendUse: uses is null ? null : g => uses.Add(g), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), @@ -78,7 +81,8 @@ public class InventoryControllerTests onClose: onClose, selection: selection ?? new SelectionState(), stackSplitQuantity: stackSplitQuantity, - itemInteraction: itemInteraction); + itemInteraction: itemInteraction, + burdenSpellbook: burdenSpellbook); private static UiButton MakeButton(uint id) { @@ -238,6 +242,37 @@ public class InventoryControllerTests Assert.Contains("50%", CaptionText(burdenText)); } + [Fact] + public void EnchantmentChange_RefreshesBurdenFromEffectiveStrength() + { + var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + var props = new PropertyBundle(); + props.Ints[5] = 1600; + objects.UpsertProperties(Player, props); + int effectiveStrength = 10; + var spellbook = new Spellbook(); + using InventoryController controller = Bind( + layout, + objects, + strengthProvider: () => effectiveStrength, + burdenSpellbook: spellbook); + + Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("106%", CaptionText(burdenText)); + + effectiveStrength = 20; + spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + 42u, 1u, 60d, Player, Bucket: 1u)); + Assert.Equal(1600f / 3000f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("53%", CaptionText(burdenText)); + + effectiveStrength = 10; + spellbook.OnPurgeAll(); + Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("106%", CaptionText(burdenText)); + } + [Fact] public void Captions_render_known_strings() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index fc3d2fe3..44b73527 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -233,6 +233,74 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + [Fact] + public void StrengthEnchantmentChange_RecomputesBurdenWithoutBaseAttributeUpdate() + { + using var session = NewSession(); + const uint playerGuid = 0x50000001u; + var objects = new ClientObjectTable(); + var character = new RuntimeCharacterState(); + character.InstallSpellMetadata(SpellTable.LoadFromReader(new StringReader( + "Spell ID,Name,Flags [Hex]\n42,Strength Test,0x4\n"))); + int movementStatsUpdated = 0; + + var router = new LiveSessionEventRouter( + session, + NoOpEntitySink(), + NoOpEnvironmentSink(), + new LiveInventorySessionBindings( + objects, + PlayerGuid: () => playerGuid, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + ExternalContainers: new ExternalContainerState()), + new LiveCharacterSessionBindings( + new CombatState(), + character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnMovementStatsUpdated: () => movementStatsUpdated++), + NewSocialBindings()); + + router.Attach(); + character.LocalPlayer.OnAttributeUpdate( + atType: 1u, ranks: 90u, start: 10u, xp: 0u); + var props = new PropertyBundle(); + props.Ints[(uint)PropertyInt.EncumbranceVal] = 16500; + objects.UpsertProperties(playerGuid, props); + Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4); + + int beforeBuff = movementStatsUpdated; + character.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 42u, + LayerId: 1u, + Duration: 60d, + CasterGuid: playerGuid, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, + StatModKey: 1u, + StatModValue: 1.2f, + Bucket: 1u)); + + Assert.Equal(120, character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength)); + Assert.Equal(16500f / 18000f, character.MovementSkills.Burden, precision: 4); + Assert.Equal(beforeBuff + 1, movementStatsUpdated); + + int beforePurge = movementStatsUpdated; + character.Spellbook.OnPurgeAll(); + + Assert.Equal(100, character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength)); + Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4); + Assert.Equal(beforePurge + 1, movementStatsUpdated); + + router.Dispose(); + } + [Fact] public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations() {