diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3ff4be06..509133a8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -211,7 +211,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | -| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | +| AP-108 | Paperdoll/AutoWield still omit Aetheria, full `AutoWieldIsLegal`/dual-wield rules, incompatible-shield/ammo blocker sequencing, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots and live doll work; inventory double-click now faithfully replaces the occupied primary weapon-ready group through server-confirmed dequip→wield in both peace and war | Special slots, illegal/off-hand cases, incompatible shield/ammo auto-removal, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d41294f6..53f25008 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -495,6 +495,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11. - **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display. - **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired. +- **Wave 4.5 implemented — retail primary-weapon switching (live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, the controller waits for authoritative `InventoryPutObjInContainer`, then sends `GetAndWieldItem` for the requested bow/sword/caster/two-hander. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 now updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index b122e6b2..fb997374 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -454,6 +454,12 @@ include dungeons. - **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail CreateObject/ParentEvent child attachment and per-frame hand-follow rendering. +- **L.1c inventory weapon switching (implemented 2026-07-12; live gate pending)** — + inventory double-click ports retail's two-stage `AutoWield`: return the occupied + primary weapon to the main pack, wait for the server move confirmation, then + wield the requested bow/sword/caster/two-hander. The shared transaction works + in peace and war; ACE owns stance-specific motions and its PropertyInt 40 update + now drives the client `CombatState`. - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md new file mode 100644 index 00000000..dbd47a99 --- /dev/null +++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md @@ -0,0 +1,108 @@ +# Retail inventory weapon-switch pseudocode + +## Scope + +Inventory double-click of a weapon while another primary weapon is equipped, +in both peace and combat modes. The UI request sequence is shared; combat-mode +animation remains server-authoritative. + +## Retail oracle + +- `ItemHolder::DetermineUseResult @ 0x00588460` classifies an owned combat-use + item as `WieldRight`. +- `CPlayerSystem::UsingItem @ 0x00562F70` dispatches that result to + `AutoWield(item, SLOT_SIDE_RIGHT, 0, 1, 0, 1)`. +- `CPlayerSystem::AutoWield @ 0x00560A60` treats + `0x03500000` (`MeleeWeapon | MissileWeapon | Held | TwoHanded`) as one + weapon-ready group. If occupied, it records the requested item, sends the + blocking weapon to the player container, and does not wield the requested + item yet. +- `CPlayerSystem::RecvNotice_ServerSaysMoveItem @ 0x00563260` retries + `AutoWield` only after the server confirms the blocking item moved. +- `ACCWeenieObject::UIAttemptWield @ 0x0058D590` sends + `GetAndWieldItem(item, equipMask)`. +- `ClientCombatSystem::OnQualityChanged @ 0x0056C1B0` reads player + PropertyInt 40 (`CombatMode`) and applies it through `SetCombatMode`. + +Addresses are from `docs/research/named-retail/symbols.json`; pseudo-C is in +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +## Cross-reference + +- ACE `Player_Inventory.cs` says the client normally sends the conflicting + item's dequip before `GetAndWieldItem`. `TryShuffleStance` changes a player + who is already in combat to Missile, Magic, or Melee after the new item is + equipped; it leaves a player in peace mode in peace. +- ACE `Creature_Combat.cs::SwitchCombatStyles` performs the visible + old-stance -> peace -> new-stance motion chain and sends + `PrivateUpdatePropertyInt(CombatMode, ...)`. +- holtburger independently confirms the action/event shapes: + `GetAndWieldItem { item_guid, equip_mask }`, `WieldObject`, and + PropertyInt 40 as `CombatMode`. + +## Faithful pseudocode + +```text +on inventory item double-click(item): + decision = DetermineUseResult(item) + if decision is WieldRight or WieldLeft or AutoSort: + auto_wield(item) + +auto_wield(item): + if another auto-wield transaction is waiting for the server: + consume the request without sending another inventory action + + if item has no valid equip location or is already equipped: + return false + + if item.validLocations intersects WEAPON_READY_MASK: + desiredMask = item.validLocations & WEAPON_READY_MASK + blocker = player's equipped item intersecting WEAPON_READY_MASK + + if blocker exists: + pending = (requestedItem = item.id, blockingItem = blocker.id) + send PutItemInContainer(blocker.id, player.id, placement = 0) + return true + + send_wield(item, desiredMask) + return true + + use the existing retail slot/auto-wear selection rules + send_wield(item, selectedMask) + +on server-confirmed item move(blocker): + if blocker matches pending.blockingItem + and blocker is now loose in the player's main pack: + requested = pending.requestedItem + clear pending before re-entry + auto_wield(requested) + +on server inventory-request failure(item, error): + if item matches pending.blockingItem or pending.requestedItem: + clear pending + +send_wield(item, mask): + update the retained inventory/paperdoll projection optimistically + send GetAndWieldItem(item.id, mask) + +on PrivateUpdatePropertyInt(property = CombatMode, value): + if value is exactly NonCombat, Melee, Missile, or Magic: + CombatState.SetCombatMode(value) +``` + +## Animation ownership + +The client must not synthesize a local bow-to-sword animation. In peace mode, +ACE leaves `CombatMode` as `NonCombat`. In combat mode, ACE selects the new +weapon's stance and sends the authoritative motion stream plus PropertyInt 40. +The inventory controller only sequences the two wire requests and waits for +the old weapon's confirmed move; the existing motion and equipped-child +render paths consume the server events. + +## Known remaining retail surface + +This port covers the primary weapon-ready group requested here. Retail +`AutoWield` also has extra blocker logic for incompatible shields and missile +ammo, plus its full `AutoWieldIsLegal` requirement checks. Those pre-existing +paperdoll/auto-wield gaps remain tracked by divergence row AP-108 rather than +being guessed from incomplete item properties. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index eed616b5..5a0ab1d3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2032,7 +2032,9 @@ public sealed class GameWindow : IDisposable sendSplitToWorld: (item, amount) => _liveSession?.SendStackableSplitTo3D(item, amount), selectedObjectId: () => _selection.SelectedObjectId ?? 0u, - stackSplitQuantity: _stackSplitQuantity); + stackSplitQuantity: _stackSplitQuantity, + sendPutItemInContainer: (item, container, placement) => + _liveSession?.SendPutItemInContainer(item, container, placement)); var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -2527,6 +2529,7 @@ public sealed class GameWindow : IDisposable // UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so // the table is populated before the render handler runs. AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid); + AcDream.Core.Net.CombatStateWiring.Wire(session, Combat); _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs new file mode 100644 index 00000000..c4c549fe --- /dev/null +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -0,0 +1,288 @@ +using System; +using AcDream.Core.Items; + +namespace AcDream.App.UI; + +/// +/// Retail CPlayerSystem::AutoWield @ 0x00560A60 transaction owner. +/// A conflicting primary weapon is first returned to the player container; +/// the requested weapon is wielded only after +/// RecvNotice_ServerSaysMoveItem @ 0x00563260 confirms that move. +/// +internal sealed class AutoWieldController : IDisposable +{ + // Retail AutoWield immediate 0x03500000: all mutually-exclusive + // primary weapon-ready locations (shield and missile ammo are separate). + internal const EquipMask WeaponReadyMask = + EquipMask.MeleeWeapon + | EquipMask.MissileWeapon + | EquipMask.Held + | EquipMask.TwoHanded; + + private static readonly EquipMask[] AutoEquipOrder = + { + EquipMask.HeadWear, + EquipMask.ChestWear, + EquipMask.AbdomenWear, + EquipMask.UpperArmWear, + EquipMask.LowerArmWear, + EquipMask.HandWear, + EquipMask.UpperLegWear, + EquipMask.LowerLegWear, + EquipMask.FootWear, + EquipMask.ChestArmor, + EquipMask.AbdomenArmor, + EquipMask.UpperArmArmor, + EquipMask.LowerArmArmor, + EquipMask.UpperLegArmor, + EquipMask.LowerLegArmor, + EquipMask.NeckWear, + EquipMask.WristWearLeft, + EquipMask.WristWearRight, + EquipMask.FingerWearLeft, + EquipMask.FingerWearRight, + EquipMask.Shield, + EquipMask.MissileAmmo, + EquipMask.MeleeWeapon, + EquipMask.MissileWeapon, + EquipMask.Held, + EquipMask.TwoHanded, + EquipMask.TrinketOne, + EquipMask.Cloak, + EquipMask.SigilOne, + EquipMask.SigilTwo, + EquipMask.SigilThree, + }; + + private readonly ClientObjectTable _objects; + private readonly Func _playerGuid; + private readonly Action? _sendWield; + private readonly Action? _sendPutItemInContainer; + private readonly Action? _toast; + + private PendingSwitch? _pendingSwitch; + private bool _disposed; + + public AutoWieldController( + ClientObjectTable objects, + Func playerGuid, + Action? sendWield, + Action? sendPutItemInContainer, + Action? toast) + { + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _sendWield = sendWield; + _sendPutItemInContainer = sendPutItemInContainer; + _toast = toast; + + _objects.ObjectMoved += OnObjectMoved; + _objects.ObjectRemoved += OnObjectRemoved; + _objects.MoveRequestFailed += OnMoveRequestFailed; + _objects.Cleared += OnObjectsCleared; + } + + /// + /// Execute the UI-facing AutoWield request. A request received while the + /// prior conflicting weapon is awaiting its server move is consumed, just + /// as retail's inventory-ready gate prevents a second concurrent request. + /// + public bool TryWield(ClientObject item) + { + ArgumentNullException.ThrowIfNull(item); + + if (_pendingSwitch is not null) + return true; + if (item.ValidLocations == EquipMask.None + || item.CurrentlyEquippedLocation != EquipMask.None) + return false; + + EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask; + if (weaponLocation != EquipMask.None) + { + ClientObject? blocker = GetEquippedObjectAtLocation( + WeaponReadyMask, priority: 0, item.ObjectId); + if (blocker is not null) + return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId); + + return SendWield(item, weaponLocation); + } + + EquipMask mask = BestAvailableEquipMask(item); + if (mask == EquipMask.None) + { + _toast?.Invoke("That slot is already in use"); + return false; + } + + return SendWield(item, mask); + } + + private bool BeginWeaponReplacement(uint requestedItemId, uint blockingItemId) + { + if (_sendPutItemInContainer is null) + { + _toast?.Invoke("That slot is already in use"); + return false; + } + + uint player = _playerGuid(); + if (player == 0) + return false; + + _pendingSwitch = new PendingSwitch(requestedItemId, blockingItemId); + + // Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0). + // Do not change the local equip projection yet: the server's move event + // is the transaction boundary and preserves its stance-specific motion. + _sendPutItemInContainer(blockingItemId, player, 0); + return true; + } + + private bool SendWield(ClientObject item, EquipMask mask) + { + if (_sendWield is null) + return false; + if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) + return false; + + // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. + _sendWield(item.ObjectId, (uint)mask); + return true; + } + + private void OnObjectMoved(ClientObject item, uint _, uint newContainerId) + { + if (_pendingSwitch is not { } pending + || item.ObjectId != pending.BlockingItemId + || newContainerId != _playerGuid() + || item.CurrentlyEquippedLocation != EquipMask.None) + return; + + // Clear before re-entry: the second AutoWield pass must see the newly + // vacant ready slot and send GetAndWieldItem for the requested weapon. + _pendingSwitch = null; + if (_objects.Get(pending.RequestedItemId) is { } requested) + TryWield(requested); + } + + private void OnMoveRequestFailed(MoveRequestFailure failure) + { + if (_pendingSwitch is { } pending + && (failure.ItemId == pending.BlockingItemId + || failure.ItemId == pending.RequestedItemId)) + _pendingSwitch = null; + } + + private void OnObjectRemoved(ClientObject item) + { + if (_pendingSwitch is { } pending + && (item.ObjectId == pending.BlockingItemId + || item.ObjectId == pending.RequestedItemId)) + _pendingSwitch = null; + } + + private void OnObjectsCleared() + => _pendingSwitch = null; + + private EquipMask BestAvailableEquipMask(ClientObject item) + { + if (ItemEquipRules.IsAutoWearItem(item)) + return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None; + + foreach (EquipMask mask in AutoEquipOrder) + { + if ((item.ValidLocations & mask) == EquipMask.None) + continue; + if (!EquipMaskOccupied(mask, item.ObjectId)) + return mask; + } + return EquipMask.None; + } + + private bool AutoWearIsLegal(ClientObject item) + { + uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId); + if ((item.Priority & priorityMask) == 0) + return true; + + EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) + & item.ValidLocations; + return GetEquippedObjectAtLocation( + occupiedLocations, item.Priority, item.ObjectId) is null; + } + + private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) + => GetEquippedObjectAtLocation(mask, priority: 0, exceptGuid) is not null; + + private uint EquippedAutoWearPriorityMask(uint exceptGuid) + { + uint mask = 0; + foreach (ClientObject item in _objects.Objects) + { + if (item.ObjectId == exceptGuid + || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None + || !IsEquippedByPlayer(item)) + continue; + mask |= item.Priority; + } + return mask; + } + + private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) + { + EquipMask mask = EquipMask.None; + foreach (ClientObject item in _objects.Objects) + { + if (item.ObjectId == exceptGuid + || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None + || !IsEquippedByPlayer(item)) + continue; + mask |= item.CurrentlyEquippedLocation; + } + return mask; + } + + private ClientObject? GetEquippedObjectAtLocation( + EquipMask locationMask, + uint priority, + uint exceptGuid) + { + if (locationMask == EquipMask.None) + return null; + + foreach (ClientObject item in _objects.Objects) + { + if (item.ObjectId == exceptGuid + || !IsEquippedByPlayer(item) + || (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None) + continue; + if ((item.Priority & priority) != 0 || priority == 0) + return item; + } + return null; + } + + private bool IsEquippedByPlayer(ClientObject item) + { + uint player = _playerGuid(); + return item.CurrentlyEquippedLocation != EquipMask.None + && (item.WielderId == player || item.ContainerId == player); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _pendingSwitch = null; + _objects.ObjectMoved -= OnObjectMoved; + _objects.ObjectRemoved -= OnObjectRemoved; + _objects.MoveRequestFailed -= OnMoveRequestFailed; + _objects.Cleared -= OnObjectsCleared; + } + + private readonly record struct PendingSwitch( + uint RequestedItemId, + uint BlockingItemId); +} diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index fdd9e4a4..4a67a863 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -18,41 +18,6 @@ public enum ItemPrimaryClickResult /// public sealed class ItemInteractionController : IDisposable { - private static readonly EquipMask[] AutoEquipOrder = - { - EquipMask.HeadWear, - EquipMask.ChestWear, - EquipMask.AbdomenWear, - EquipMask.UpperArmWear, - EquipMask.LowerArmWear, - EquipMask.HandWear, - EquipMask.UpperLegWear, - EquipMask.LowerLegWear, - EquipMask.FootWear, - EquipMask.ChestArmor, - EquipMask.AbdomenArmor, - EquipMask.UpperArmArmor, - EquipMask.LowerArmArmor, - EquipMask.UpperLegArmor, - EquipMask.LowerLegArmor, - EquipMask.NeckWear, - EquipMask.WristWearLeft, - EquipMask.WristWearRight, - EquipMask.FingerWearLeft, - EquipMask.FingerWearRight, - EquipMask.Shield, - EquipMask.MissileAmmo, - EquipMask.MeleeWeapon, - EquipMask.MissileWeapon, - EquipMask.Held, - EquipMask.TwoHanded, - EquipMask.TrinketOne, - EquipMask.Cloak, - EquipMask.SigilOne, - EquipMask.SigilTwo, - EquipMask.SigilThree, - }; - private const long RetailUseThrottleMs = 200; private const long RetailDoubleClickMs = 500; @@ -77,6 +42,7 @@ public sealed class ItemInteractionController : IDisposable private readonly InteractionState _interactionState; private readonly Func _selectedObjectId; private readonly StackSplitQuantityState? _stackSplitQuantity; + private readonly AutoWieldController _autoWield; private long _lastUseMs = long.MinValue / 2; private uint _consumedPrimaryClickTarget; @@ -105,7 +71,8 @@ public sealed class ItemInteractionController : IDisposable Action? auxiliaryAction = null, Action? sendSplitToWorld = null, Func? selectedObjectId = null, - StackSplitQuantityState? stackSplitQuantity = null) + StackSplitQuantityState? stackSplitQuantity = null, + Action? sendPutItemInContainer = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -129,6 +96,12 @@ public sealed class ItemInteractionController : IDisposable _stackSplitQuantity = stackSplitQuantity; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; + _autoWield = new AutoWieldController( + _objects, + _playerGuid, + _sendWield, + sendPutItemInContainer, + _toast); } public event Action? StateChanged; @@ -364,7 +337,7 @@ public sealed class ItemInteractionController : IDisposable case ItemPolicyActionKind.WieldLeft: case ItemPolicyActionKind.AutoSort: if (_objects.Get(action.ObjectId) is { } item) - acted |= TryAutoWield(item); + acted |= _autoWield.TryWield(item); break; case ItemPolicyActionKind.OpenContainedContainer: case ItemPolicyActionKind.SendUse: @@ -472,6 +445,7 @@ public sealed class ItemInteractionController : IDisposable if (_disposed) return; _disposed = true; _interactionState.Changed -= OnInteractionModeChanged; + _autoWield.Dispose(); } /// Retail UseDone (0x01C7) releases one UI busy reference. @@ -491,119 +465,6 @@ public sealed class ItemInteractionController : IDisposable return true; } - private bool TryAutoWield(ClientObject item) - { - if (item.ValidLocations == EquipMask.None - || item.CurrentlyEquippedLocation != EquipMask.None) - return false; - - EquipMask mask = BestAvailableEquipMask(item); - if (mask == EquipMask.None) - { - _toast?.Invoke("That slot is already in use"); - return false; - } - - if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) - return false; - - _sendWield?.Invoke(item.ObjectId, (uint)mask); - return true; - } - - private EquipMask BestAvailableEquipMask(ClientObject item) - { - if (ItemEquipRules.IsAutoWearItem(item)) - return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None; - - return FirstAvailableEquipMask(item); - } - - private bool AutoWearIsLegal(ClientObject item) - { - uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId); - if ((item.Priority & priorityMask) == 0) - return true; - - EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations; - return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null; - } - - private EquipMask FirstAvailableEquipMask(ClientObject item) - { - foreach (var mask in AutoEquipOrder) - { - if ((item.ValidLocations & mask) == EquipMask.None) - continue; - if (!EquipMaskOccupied(mask, item.ObjectId)) - return mask; - } - return EquipMask.None; - } - - private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) - { - foreach (var o in _objects.Objects) - { - if (o.ObjectId == exceptGuid) - continue; - if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None) - continue; - if (IsEquippedByPlayer(o)) - return true; - } - return false; - } - - private uint EquippedAutoWearPriorityMask(uint exceptGuid) - { - uint mask = 0; - foreach (var o in _objects.Objects) - { - if (o.ObjectId == exceptGuid) - continue; - if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) - continue; - if (IsEquippedByPlayer(o)) - mask |= o.Priority; - } - return mask; - } - - private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) - { - EquipMask mask = EquipMask.None; - foreach (var o in _objects.Objects) - { - if (o.ObjectId == exceptGuid) - continue; - if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) - continue; - if (IsEquippedByPlayer(o)) - mask |= o.CurrentlyEquippedLocation; - } - return mask; - } - - private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid) - { - if (locationMask == EquipMask.None) - return null; - - foreach (var o in _objects.Objects) - { - if (o.ObjectId == exceptGuid) - continue; - if (!IsEquippedByPlayer(o)) - continue; - if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None) - continue; - if ((o.Priority & priority) != 0 || priority == 0) - return o; - } - return null; - } - private static bool IsContainer(ClientObject item) => item.ContainerTypeHint != 0 || item.Type.HasFlag(ItemType.Container) diff --git a/src/AcDream.Core.Net/CombatStateWiring.cs b/src/AcDream.Core.Net/CombatStateWiring.cs new file mode 100644 index 00000000..44f2514b --- /dev/null +++ b/src/AcDream.Core.Net/CombatStateWiring.cs @@ -0,0 +1,47 @@ +using AcDream.Core.Combat; + +namespace AcDream.Core.Net; + +/// +/// Routes the local player's server-owned combat-mode quality into +/// . Retail +/// ClientCombatSystem::OnQualityChanged @ 0x0056C1B0 reads +/// PropertyInt 40 and calls SetCombatMode on every change. +/// +public static class CombatStateWiring +{ + public const uint CombatModePropertyId = 40u; + + public static void Wire(WorldSession session, CombatState combat) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(combat); + + session.PlayerIntPropertyUpdated += update => + ApplyPlayerIntProperty(combat, update.Property, update.Value); + } + + /// + /// Pure event adapter exposed for conformance tests. Composite or unknown + /// values are ignored: retail's quality contains one concrete mode. + /// + public static bool ApplyPlayerIntProperty( + CombatState combat, + uint property, + int value) + { + ArgumentNullException.ThrowIfNull(combat); + if (property != CombatModePropertyId) + return false; + + CombatMode mode = (CombatMode)value; + if (mode is not (CombatMode.NonCombat + or CombatMode.Melee + or CombatMode.Missile + or CombatMode.Magic)) + return false; + + combat.SetCombatMode(mode); + return true; + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index d89e41d8..580614c5 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -314,7 +314,7 @@ public static class GameEventWiring string itemInfo = item is null ? "unknown" : $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}"; - bool rolledBack = items.RollbackMove(p.Value.ItemGuid); + bool rolledBack = items.RejectMove(p.Value.ItemGuid, p.Value.WeenieError); Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}"); }); diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 5ea4588d..70dc5483 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -10,6 +10,17 @@ namespace AcDream.Core.Items; /// public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType); +/// +/// Server rejection of an inventory move request. +/// is true when the table restored a locally optimistic move; false for +/// server-confirmed transactions which deliberately left the local projection +/// unchanged while waiting. +/// +public readonly record struct MoveRequestFailure( + uint ItemId, + uint WeenieError, + bool RolledBack); + /// /// The client's table of every server object (retail weenie_object_table / /// CObjectMaint). Resolve by guid via Get. @@ -74,12 +85,22 @@ public sealed class ClientObjectTable /// public event Action? MoveRolledBack; + /// + /// Fires for every InventoryServerSaveFailed response, including requests + /// which did not mutate the table optimistically. Transaction coordinators + /// use this to abandon server-confirmed multi-step moves without timers. + /// + public event Action? MoveRequestFailed; + /// Fires when an object is removed from the session. public event Action? ObjectRemoved; /// Fires when an object's properties are updated (typically after Appraise). public event Action? ObjectUpdated; + /// Fires after all session object and pending-move state is flushed. + public event Action? Cleared; + /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield; /// the typed mirror maintains on /// . @@ -254,6 +275,19 @@ public sealed class ClientObjectTable return true; } + /// + /// Reconcile an InventoryServerSaveFailed response and publish the failure + /// even when no optimistic snapshot exists. Retail's blocked AutoWield path + /// waits for the server before changing its local item lists, so failure is + /// still a meaningful transaction event in that case. + /// + public bool RejectMove(uint itemId, uint weenieError) + { + bool rolledBack = RollbackMove(itemId); + MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack)); + return rolledBack; + } + /// /// Handle a server-driven remove (destroyed item, dropped into 3D /// space, stolen, etc). @@ -592,5 +626,6 @@ public sealed class ClientObjectTable _containers.Clear(); _containerIndex.Clear(); _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) + Cleared?.Invoke(); } } diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index a08c19b4..a04aa8f6 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -18,11 +18,13 @@ public sealed class ItemInteractionControllerTests public readonly List Examines = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Item, uint Mask)> Wields = new(); + public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List Drops = new(); public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List Toasts = new(); public readonly StackSplitQuantityState SplitQuantity = new(); public uint SelectedObject; + public bool NonCombatMode; public long Now = 1_000; public Harness() @@ -54,7 +56,10 @@ public sealed class ItemInteractionControllerTests toast: Toasts.Add, sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)), selectedObjectId: () => SelectedObject, - stackSplitQuantity: SplitQuantity); + stackSplitQuantity: SplitQuantity, + inNonCombatMode: () => NonCombatMode, + sendPutItemInContainer: (item, container, placement) => + Puts.Add((item, container, placement))); } public ItemInteractionController Controller { get; } @@ -412,6 +417,112 @@ public sealed class ItemInteractionControllerTests Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WeaponReplacement_inPeaceOrWar_unwieldsThenWieldsAfterServerConfirm( + bool nonCombatMode) + { + var h = new Harness { NonCombatMode = nonCombatMode }; + const uint sword = 0x50000B01u; + const uint bow = 0x50000B02u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = sword, + Name = "Sword", + Type = ItemType.MeleeWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MeleeWeapon, + }); + h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon); + h.AddContained(bow, item => + { + item.Name = "Bow"; + item.Type = ItemType.MissileWeapon; + item.CombatUse = 1; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + + Assert.Equal(new[] { (sword, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(EquipMask.MeleeWeapon, + h.Objects.Get(sword)!.CurrentlyEquippedLocation); + Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId); + + // Authoritative 0x0022: only now does retail retry AutoWield. + h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + + Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + Assert.Equal(EquipMask.None, + h.Objects.Get(sword)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.MissileWeapon, + h.Objects.Get(bow)!.CurrentlyEquippedLocation); + Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); + } + + [Fact] + public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer() + { + var h = new Harness(); + const uint sword = 0x50000B11u; + const uint bow = 0x50000B12u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = sword, + Type = ItemType.MeleeWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MeleeWeapon, + }); + h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon); + h.AddContained(bow, item => + { + item.Type = ItemType.MissileWeapon; + item.CombatUse = 1; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + h.Now += 200; + Assert.True(h.Controller.ActivateItem(bow)); + + Assert.Single(h.Puts); + Assert.Empty(h.Wields); + } + + [Fact] + public void WeaponReplacement_serverReject_clearsPendingTransactionForRetry() + { + var h = new Harness(); + const uint sword = 0x50000B21u; + const uint bow = 0x50000B22u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = sword, + Type = ItemType.MeleeWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MeleeWeapon, + }); + h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon); + h.AddContained(bow, item => + { + item.Type = ItemType.MissileWeapon; + item.CombatUse = 1; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + Assert.False(h.Objects.RejectMove(sword, 0x04FFu)); + h.Now += 200; + Assert.True(h.Controller.ActivateItem(bow)); + + Assert.Equal(2, h.Puts.Count); + Assert.Empty(h.Wields); + Assert.Equal(EquipMask.MeleeWeapon, + h.Objects.Get(sword)!.CurrentlyEquippedLocation); + } + [Fact] public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically() { diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 29d7f5db..85621e6e 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -11,6 +11,8 @@ public sealed class RetailUiInteractionFlowTests private const uint Player = 0x50000001u; private const uint Hauberk = 0x50001001u; private const uint HealthKit = 0x50001002u; + private const uint Sword = 0x50001003u; + private const uint Bow = 0x50001004u; private const uint SlotsButtonId = 0x100005BEu; private const uint ChestArmorSlotId = 0x100005ACu; // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value. @@ -32,6 +34,7 @@ public sealed class RetailUiInteractionFlowTests public readonly List Uses = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Item, uint Mask)> Wields = new(); + public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly AcDream.Core.Selection.SelectionState Selection = new(); public readonly List Drops = new(); public long Now = 10_000; @@ -165,7 +168,9 @@ public sealed class RetailUiInteractionFlowTests sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), sendWield: (item, mask) => Wields.Add((item, mask)), sendDrop: Drops.Add, - nowMs: () => Now); + nowMs: () => Now, + sendPutItemInContainer: (item, container, placement) => + Puts.Add((item, container, placement))); InventoryController.Bind( Layout, @@ -285,6 +290,46 @@ public sealed class RetailUiInteractionFlowTests Assert.True(state.Success, state.Message); } + [Fact] + public void InventoryDoubleClick_bow_replacesSwordOnlyAfterConfirmedUnwield() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = Sword, + Name = "Sword", + Type = ItemType.MeleeWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MeleeWeapon, + IconId = 0x06001234u, + }); + h.Objects.MoveItem(Sword, Player, -1, EquipMask.MeleeWeapon); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = Bow, + Name = "Bow", + Type = ItemType.MissileWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MissileWeapon, + IconId = 0x06001235u, + }); + h.Objects.MoveItem(Bow, Player, 0); + h.BindInventoryInteraction(); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(Bow, ItemDragSource.Inventory)); + Assert.Equal(new[] { (Sword, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(EquipMask.MeleeWeapon, + h.Objects.Get(Sword)!.CurrentlyEquippedLocation); + + h.Objects.MoveItem(Sword, Player, 0, EquipMask.None); + + Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + Assert.Equal(EquipMask.MissileWeapon, + h.Objects.Get(Bow)!.CurrentlyEquippedLocation); + } + [Fact] public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot() { diff --git a/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs b/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs new file mode 100644 index 00000000..b7e52df1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs @@ -0,0 +1,42 @@ +using AcDream.Core.Combat; + +namespace AcDream.Core.Net.Tests; + +/// +/// Conformance pins for retail ClientCombatSystem::OnQualityChanged +/// (0x0056C1B0): player PropertyInt 40 is the authoritative combat mode. +/// +public sealed class CombatStateWiringTests +{ + [Theory] + [InlineData((int)CombatMode.NonCombat)] + [InlineData((int)CombatMode.Melee)] + [InlineData((int)CombatMode.Missile)] + [InlineData((int)CombatMode.Magic)] + public void CombatModeProperty_appliesConcreteRetailMode(int value) + { + var combat = new CombatState(); + + Assert.True(CombatStateWiring.ApplyPlayerIntProperty( + combat, + CombatStateWiring.CombatModePropertyId, + value)); + + Assert.Equal((CombatMode)value, combat.CurrentMode); + } + + [Theory] + [InlineData(39u, (int)CombatMode.Missile)] + [InlineData(40u, (int)CombatMode.Undef)] + [InlineData(40u, (int)CombatMode.ValidCombat)] + [InlineData(40u, 0x10)] + public void NonCombatQualityOrInvalidValue_isIgnored(uint property, int value) + { + var combat = new CombatState(); + + Assert.False(CombatStateWiring.ApplyPlayerIntProperty( + combat, property, value)); + + Assert.Equal(CombatMode.NonCombat, combat.CurrentMode); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index b95b361b..81ee27a3 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -693,6 +693,40 @@ public sealed class ClientObjectTableTests public void WieldItemOptimistic_unknownItem_false() => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); + [Fact] + public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure() + { + var table = new ClientObjectTable(); + MoveRequestFailure? seen = null; + table.MoveRequestFailed += failure => seen = failure; + + Assert.False(table.RejectMove(0xDEADu, 0x04FFu)); + + Assert.Equal( + new MoveRequestFailure(0xDEADu, 0x04FFu, RolledBack: false), + seen); + } + + [Fact] + public void RejectMove_withOptimisticMutation_rollsBackAndPublishesFailure() + { + var table = new ClientObjectTable(); + const uint item = 0x943u, player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject { ObjectId = item }); + table.MoveItem(item, pack, 2); + table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon); + MoveRequestFailure? seen = null; + table.MoveRequestFailed += failure => seen = failure; + + Assert.True(table.RejectMove(item, 0x04FFu)); + + Assert.Equal(pack, table.Get(item)!.ContainerId); + Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation); + Assert.Equal( + new MoveRequestFailure(item, 0x04FFu, RolledBack: true), + seen); + } + [Fact] public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending) {