fix(movement): invalidate burden on enchantment changes

This commit is contained in:
Erik 2026-07-31 10:16:27 +02:00
parent 2b9dfec9d7
commit d6e8b60303
13 changed files with 231 additions and 25 deletions

View file

@ -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 ## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down
**Status:** DONE — 2026-07-31 (implementation + user live gate) **Status:** DONE — 2026-07-31 (implementation + user live gate)

View file

@ -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: capture-driven slope-slide residual is also closed and user-accepted:
`CTransition::validate_transition` now performs retail's non-OK-only `CTransition::validate_transition` now performs retail's non-OK-only
remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`. 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.
--- ---

View file

@ -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 and the complete Release suite passes 10,062 tests / 5 skips. The user
accepted repeated uphill runs while pressing into the stair sides. Evidence: accepted repeated uphill runs while pressing into the stair sides. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`. `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, Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing, and #269's slope-stop feel flat pop, uphill landing, and #269's slope-stop feel

View file

@ -54,11 +54,16 @@ InqLoad(this, &loadOut):
return 1 // always succeeds for CACQualities (has vtable) return 1 // always succeeds for CACQualities (has vtable)
``` ```
This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()` The property/capacity shape matches acdream's
/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop `IndicatorBarController.UpdateBurden()` /
0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the `InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6
wire value is absent) — already ported, already correct, already tested via aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire
the UI. **`AcDream.Core.Items.BurdenMath` 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 (`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at
the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for
citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather
@ -317,8 +322,10 @@ Runtime (AcDream.Runtime, presentation-free):
- onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...) - onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
- NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved, - NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength) ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
+ Spellbook.EnchantmentsChanged
-> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal, -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden) SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
using effective/enchantment-adjusted Strength
-> character.Character.MovementSkills.UpdateBurden(ratio) -> character.Character.MovementSkills.UpdateBurden(ratio)
- NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina) - NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
-> character.Character.MovementSkills.UpdateStamina(current) -> character.Character.MovementSkills.UpdateStamina(current)

View file

@ -99,3 +99,12 @@ InputDispatcher / PlayerMovementController
5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31. 5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31.
See See
`docs/research/2026-07-31-271-stair-side-slide-capture.md`. `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.

View file

@ -614,9 +614,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Character.Spellbook, d.Character.Spellbook,
d.Inventory.Objects, d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
() => d.Character.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength),
is { } strength ? (int?)strength.Current : null,
() => late.Session.LinkStatus, () => late.Session.LinkStatus,
d.ClientTime, d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(), () => late.Session.CurrentSession?.RequestLinkStatusPing(),
@ -656,9 +655,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
() => d.Character.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength),
is { } strength ? (int?)strength.Current : null, d.Character.Spellbook,
guid => late.Session.CurrentSession?.SendUse(guid), guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) => (item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer( late.Session.CurrentSession?.SendPutItemInContainer(

View file

@ -91,7 +91,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame); _miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession; _endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; bindings.Spellbook.EnchantmentsChanged += OnEnchantmentsChanged;
bindings.Objects.ObjectAdded += OnObjectChanged; bindings.Objects.ObjectAdded += OnObjectChanged;
bindings.Objects.ObjectUpdated += OnObjectChanged; bindings.Objects.ObjectUpdated += OnObjectChanged;
bindings.Objects.ObjectRemoved += OnObjectChanged; bindings.Objects.ObjectRemoved += OnObjectChanged;
@ -229,6 +229,11 @@ public sealed class IndicatorBarController : IRetainedPanelController
private void OnObjectMoved(ClientObjectMove _) => UpdateBurden(); private void OnObjectMoved(ClientObjectMove _) => UpdateBurden();
private void OnContainerContentsReplaced(uint _) => UpdateBurden(); private void OnContainerContentsReplaced(uint _) => UpdateBurden();
private void OnObjectsCleared() => UpdateBurden(); private void OnObjectsCleared() => UpdateBurden();
private void OnEnchantmentsChanged()
{
UpdateEnchantments();
UpdateBurden();
}
private void UpdateBurden() private void UpdateBurden()
{ {
@ -253,7 +258,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments; _bindings.Spellbook.EnchantmentsChanged -= OnEnchantmentsChanged;
_bindings.Objects.ObjectAdded -= OnObjectChanged; _bindings.Objects.ObjectAdded -= OnObjectChanged;
_bindings.Objects.ObjectUpdated -= OnObjectChanged; _bindings.Objects.ObjectUpdated -= OnObjectChanged;
_bindings.Objects.ObjectRemoved -= OnObjectChanged; _bindings.Objects.ObjectRemoved -= OnObjectChanged;

View file

@ -3,6 +3,7 @@ using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -43,6 +44,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Func<int?> _strength; private readonly Func<int?> _strength;
private readonly Spellbook? _burdenSpellbook;
private readonly Func<string>? _ownerName; private readonly Func<string>? _ownerName;
private readonly UiItemList? _contentsGrid; private readonly UiItemList? _contentsGrid;
@ -96,13 +98,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint>? notifyMergeAttempt, Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction, ItemInteractionController? itemInteraction,
Action? onClose, Action? onClose,
StackSplitQuantityState? stackSplitQuantity) StackSplitQuantityState? stackSplitQuantity,
Spellbook? burdenSpellbook)
{ {
_objects = objects; _objects = objects;
_playerGuid = playerGuid; _playerGuid = playerGuid;
_iconIds = iconIds; _iconIds = iconIds;
_dragIconIds = dragIconIds; _dragIconIds = dragIconIds;
_strength = strength; _strength = strength;
_burdenSpellbook = burdenSpellbook;
_ownerName = ownerName; _ownerName = ownerName;
_sendUse = sendUse; _sendUse = sendUse;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
@ -195,6 +199,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared; _objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged; _selection.Changed += OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged += RefreshBurden;
if (_itemInteraction is not null) if (_itemInteraction is not null)
{ {
_itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.StateChanged += OnInteractionStateChanged;
@ -244,14 +250,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
ItemInteractionController? itemInteraction = null, ItemInteractionController? itemInteraction = null,
Action? onClose = null, Action? onClose = null,
StackSplitQuantityState? stackSplitQuantity = null, StackSplitQuantityState? stackSplitQuantity = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null) Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null,
Spellbook? burdenSpellbook = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection, => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont, ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendPutItemInContainer, sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge, sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction, notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity); onClose, stackSplitQuantity, burdenSpellbook);
private void OnObjectChanged(ClientObject o) private void OnObjectChanged(ClientObject o)
{ {
@ -944,6 +951,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged -= RefreshBurden;
if (_contentsGrid is not null) if (_contentsGrid is not null)
{ {
_contentsGrid.PrimaryItemPressed = null; _contentsGrid.PrimaryItemPressed = null;

View file

@ -122,6 +122,7 @@ public sealed record InventoryRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength, Func<int?> Strength,
Spellbook Spellbook,
Action<uint>? SendUse, Action<uint>? SendUse,
Action<uint, uint, int>? SendPutItemInContainer, Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer, Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
@ -1816,7 +1817,8 @@ public sealed class RetailUiRuntime : IDisposable
notifyMergeAttempt, b.ItemInteraction, notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory), () => CloseWindow(WindowNames.Inventory),
StackSplitQuantity, StackSplitQuantity,
b.ResolveDragIcon); b.ResolveDragIcon,
b.Spellbook);
InventoryPanelController = inventory; InventoryPanelController = inventory;
PaperdollController paperdoll = PaperdollController.Bind( PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction,

View file

@ -229,6 +229,10 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
if (kind == LocalPlayerState.AttributeKind.Strength) if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character); 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 // Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5). // stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
@ -370,8 +374,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{ {
uint player = inventory.PlayerGuid(); uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player); ClientObject? playerObject = inventory.Objects.Get(player);
int strength = (int)(character.Character.LocalPlayer int strength = character.Character.LocalPlayer
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u); .GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0;
int aug = playerObject?.Properties.GetInt( int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0; (uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug); int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);

View file

@ -89,6 +89,25 @@ public sealed class IndicatorBarControllerTests
Assert.Equal(expectedState, button.ActiveRetailStateId); 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] [Fact]
public void Burden_ClickOpensCharacterInformationPanel() public void Burden_ClickOpensCharacterInformationPanel()
{ {
@ -205,7 +224,7 @@ public sealed class IndicatorBarControllerTests
spellbook, spellbook,
objects, objects,
() => Player, () => Player,
() => strength, () => harness.Strength,
() => harness.LinkStatus, () => harness.LinkStatus,
() => harness.Time, () => harness.Time,
harness.ToggledPanels.Add, harness.ToggledPanels.Add,
@ -222,7 +241,7 @@ public sealed class IndicatorBarControllerTests
public ImportedLayout Layout { get; } = layout; public ImportedLayout Layout { get; } = layout;
public Spellbook Spellbook { get; } = spellbook; public Spellbook Spellbook { get; } = spellbook;
public ClientObjectTable Objects { get; } = objects; public ClientObjectTable Objects { get; } = objects;
public int Strength { get; } = strength; public int Strength { get; set; } = strength;
public List<uint> ToggledPanels { get; } = []; public List<uint> ToggledPanels { get; } = [];
public double Time { get; set; } public double Time { get; set; }
public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d); public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d);

View file

@ -3,6 +3,7 @@ using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -63,10 +64,12 @@ public class InventoryControllerTests
Action? onClose = null, Action? onClose = null,
SelectionState? selection = null, SelectionState? selection = null,
StackSplitQuantityState? stackSplitQuantity = null, StackSplitQuantityState? stackSplitQuantity = null,
ItemInteractionController? itemInteraction = null) ItemInteractionController? itemInteraction = null,
Func<int?>? strengthProvider = null,
Spellbook? burdenSpellbook = null)
=> InventoryController.Bind(layout, objects, () => Player, => InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null, strength: strengthProvider ?? (() => strength), datFont: null,
ownerName: ownerName is null ? null : () => ownerName, ownerName: ownerName is null ? null : () => ownerName,
sendUse: uses is null ? null : g => uses.Add(g), sendUse: uses is null ? null : g => uses.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
@ -78,7 +81,8 @@ public class InventoryControllerTests
onClose: onClose, onClose: onClose,
selection: selection ?? new SelectionState(), selection: selection ?? new SelectionState(),
stackSplitQuantity: stackSplitQuantity, stackSplitQuantity: stackSplitQuantity,
itemInteraction: itemInteraction); itemInteraction: itemInteraction,
burdenSpellbook: burdenSpellbook);
private static UiButton MakeButton(uint id) private static UiButton MakeButton(uint id)
{ {
@ -238,6 +242,37 @@ public class InventoryControllerTests
Assert.Contains("50%", CaptionText(burdenText)); 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] [Fact]
public void Captions_render_known_strings() public void Captions_render_known_strings()
{ {

View file

@ -233,6 +233,74 @@ public sealed class LiveSessionEventRouterTests
router.Dispose(); 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] [Fact]
public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations() public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations()
{ {