From 0392c6d721f54a010b8fb5404384b3d02cc9273e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 18:35:29 +0200 Subject: [PATCH] fix(items): finish corpse pickup and combat switching Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain. Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged. Release build succeeds and all 5,885 tests pass with five intentional skips. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 25 +++ .../retail-divergence-register.md | 1 + ...6-07-12-retail-weapon-switch-pseudocode.md | 38 +++- ...l-external-container-looting-pseudocode.md | 40 +++- src/AcDream.App/Rendering/GameWindow.cs | 74 ++++--- src/AcDream.App/UI/AutoWieldController.cs | 180 ++++++++++++++++-- .../UI/ItemInteractionController.cs | 37 +++- .../UI/Layout/ExternalContainerController.cs | 12 ++ .../UI/Layout/InventoryController.cs | 29 +++ src/AcDream.App/UI/RetailUiRuntime.cs | 2 + .../ExternalContainerLifecycleController.cs | 8 +- .../Items/ExternalContainerState.cs | 23 ++- .../UI/ItemInteractionControllerTests.cs | 143 +++++++++++++- .../ExternalContainerControllerTests.cs | 31 ++- .../UI/Layout/InventoryControllerTests.cs | 43 +++++ ...ternalContainerLifecycleControllerTests.cs | 6 +- .../Items/ExternalContainerStateTests.cs | 20 +- 17 files changed, 631 insertions(+), 81 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dd9f1a3b..0a847ae6 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -516,6 +516,18 @@ optimistic pre-confirm projection instead of treating equipment as backpack contents. The connected gate completed NonCombat → Missile → NonCombat → Missile, including a Jump press, without a server rejection. +**2026-07-17 active-switch follow-up:** A connected Magic wand -> missile +weapon trace exposed ACE's queued `Magic -> Melee -> NonCombat -> Missile -> +NonCombat` tail. `AutoWieldController` now retains the desired ready mode +through every confirmed blocker move and, only for a transaction that began +in active combat, arms settlement on authoritative `WieldObject`. An already- +correct first mode notice clears it; ACE's observed `NonCombat -> Missile -> +NonCombat` sequence sends the normal mode request from its trailing notice, +after the queued callback that caused that notice. +This keeps the missile bar present while retaining +ACE ownership of every state and motion; peace-mode switching is unchanged. +The narrow ACE compatibility difference is registered as AP-118. + **Research:** `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md` **Acceptance:** Relaunch with the crossbow already equipped. The combat button @@ -2294,6 +2306,19 @@ R5/R6 touches the action list. **Where:** **Research:** `docs/research/2026-07-17-retail-external-container-looting-pseudocode.md`. +**2026-07-17 interaction-routing follow-up:** The production binding routes +keyboard R through the same application Use adapter as retained double-click, +preserving approach-then-send completion. It also supplies the live external +root to `DetermineUseResult`, so double-clicking a corpse child requests +loot-to-pack rather than ordinary item use. + +**2026-07-17 pending/replacement follow-up:** Double-click loot now follows +`ShowPendingInPlayer`: it inserts a waiting projection in slot 1 of the current +owned pack and sends that same destination/placement on the wire. A new corpse +request now retires the previous ground-object presentation immediately, as +retail `SetGroundObject` does. This removes the stale range watcher whose close +Use canceled ACE's active MoveTo chain before the new corpse could open. + **Acceptance:** replacement sends `NoLongerViewingContents` once; authored close/range sends `Use` once and waits for `0x0052`; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; corpse Use never attempts to pick up the corpse; a full-stack loot drop immediately shows a ghosted pending copy at the chosen destination slot and confirmation/failure clears it; canonical transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b0c52719..a875bce1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -215,6 +215,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | +| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, an immediately correct mode clears settlement; local ACE's observed `NonCombat -> ready mode -> NonCombat` tail causes one normal `ChangeCombatMode` request from the trailing notice. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during wand-to-bow replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later replacement/logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | ## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md index 1c5dc70a..1a631356 100644 --- a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md +++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md @@ -111,6 +111,17 @@ on server-confirmed WieldObject(item): and pending.blockingItem is none: clear pending + if the transaction began in an active combat mode: + // ACE compatibility boundary; see below. + retain the ready mode implied by the confirmed equip location + +on combat-mode notices after confirmed WieldObject: + if the first notice is the retained ready mode: + clear the retained mode; the server already settled correctly + else if notices form NonCombat -> retained ready mode -> NonCombat: + clear the retained mode + request that ready mode from ACE's trailing NonCombat notice + on PrivateUpdatePropertyInt(property = CombatMode, value): if value is exactly NonCombat, Melee, Missile, or Magic: CombatState.SetCombatMode(value) @@ -119,11 +130,28 @@ on PrivateUpdatePropertyInt(property = CombatMode, 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. +ACE leaves `CombatMode` as `NonCombat`. The inventory controller 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. + +### ACE active-combat compatibility boundary + +A connected 2026-07-17 trace of Magic wand -> missile weapon showed ACE emit +`Magic -> Melee -> NonCombat -> Missile -> NonCombat`. This is a consequence of +ACE `Player_Inventory.TryShuffleStance` plus its queued dequip callbacks: the +server does select Missile for the new weapon, but a later queued callback +publishes NonCombat after the authoritative `WieldObject`. Retail's client does +not need a second request against the retail server. + +acdream therefore preserves the user's desired ready mode only when AutoWield +begins in active combat. Authoritative `WieldObject` arms settlement. A server +whose first subsequent notice is already the desired mode needs no request. +Local ACE instead emits `NonCombat -> desired mode -> NonCombat`; the client +sends one normal `ChangeCombatMode` request from that trailing notice, +necessarily after the queued callback that caused it. All resulting state and animation remain server- +authoritative. No update is ignored, delayed, or locally fabricated, and a +transaction begun in peace sends no mode request. This narrow server- +compatibility adaptation is recorded as AP-118. The request is not complete when `GetAndWieldItem` is merely sent. Retail `UIAttemptWield` records `prevRequest = IR_WIELD` and leaves the item waiting diff --git a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md index 2d164c0f..733af021 100644 --- a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md +++ b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md @@ -22,6 +22,10 @@ Named Sept-2013 retail symbols: - `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` - `ClientUISystem::SetGroundObject @ 0x00564510` - `ClientUISystem::OnViewContents @ 0x00565870` +- `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0` +- `gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50` +- `gmInventoryUI::RecvNotice_EndPendingInPlayer @ 0x004A68B0` +- `UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40` - `ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680` - `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0` - `ItemHolder::AttemptToPlaceInContainer @ 0x00588140` @@ -69,9 +73,22 @@ thumb, and arrow sprites from LayoutDesc `0x2100003E`. ```text UseWorldObject(objectId): decision = ItemHolder policy - if decision is SetGroundObject: - expectedExternalRoot = objectId send Use(objectId) + if decision is SetGroundObject: + SetGroundObject(objectId, notifyServer = true) + +SetGroundObject(newRoot, notifyServer): + if currentExternalRoot == newRoot: + return + oldRoot = currentExternalRoot + if oldRoot != 0: + retire oldRoot's temporary contents tree + publish SetGroundObject(0) to the external panel + if notifyServer: + send NoLongerViewingContents(oldRoot) + currentExternalRoot = newRoot + expectedExternalRoot = newRoot + // The panel remains empty until ViewContents for newRoot is accepted. UseCorpse(corpseId): // A corpse is a TYPE_CONTAINER carrying BF_STUCK | BF_CORPSE. @@ -86,11 +103,6 @@ OnViewContents(containerId, orderedEntries): if containerId != expectedExternalRoot: return - old = currentExternalRoot - if old != 0 and old != containerId: - objectTable.StopViewingContentsTree(old) - send NoLongerViewingContents(old) exactly once - currentExternalRoot = containerId expectedExternalRoot = containerId externalPanel.SetGroundObject(containerId) @@ -138,6 +150,14 @@ OnExternalItemDoubleClick(itemId): ItemHolder.UseObject(itemId) // Because item.ContainerId is currentExternalRoot, retail policy emits // PlaceInBackpack; the server remains authoritative for the move. + destination = current open owned pack + destinationList.RemoveTrailingEmptyItem() + destinationList.AddEmptySlot(at index 0) + destinationList.AddItem(itemId) + destinationList.pendingItem.SetWaitingState(true) + send PutItemInContainer(itemId, destination, placement = 0) + // The pending item therefore reserves the FIRST slot and shifts the + // existing packed list to the right until the server confirms or rejects. DragExternalItemToOwnedInventory(itemId, placement, splitAmount): if splitAmount < full stack: @@ -171,8 +191,10 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount): ## Architectural mapping - `ExternalContainerState` in Core owns only expected/current external-root - identity and transition ordering. It filters ACE's nested `ViewContents` - packets so they cannot replace the visible root. + identity and transition ordering. A replacement request retires current + presentation immediately, matching `SetGroundObject`; accepted + `ViewContents` later opens the requested root. It filters ACE's nested + `ViewContents` packets so they cannot replace the visible root. - `ExternalContainerLifecycleController` in App observes those transitions, retires the replaced root's temporary preview tree, then owns the replacement-only `NoLongerViewingContents` network side effect. The wire diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index df99fd4d..12fd1861 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1136,7 +1136,13 @@ public sealed class GameWindow : IDisposable // rotating to face the target). Only set for close-range Use/PickUp; // far-range sends fire the wire packet immediately at SendUse/SendPickUp // time. Cleared before the deferred send fires — single-fire, no retry. - private (uint Guid, bool IsPickup)? _pendingPostArrivalAction; + private PendingPostArrivalAction? _pendingPostArrivalAction; + + private readonly record struct PendingPostArrivalAction( + uint Guid, + bool IsPickup, + uint DestinationContainerId = 0u, + int Placement = 0); // R5-V2: the local player's CPhysicsObj stand-in — owns the player's // TargetManager voyeur system, registered in _physicsHosts so remote @@ -2127,7 +2133,13 @@ public sealed class GameWindow : IDisposable _itemInteractionController = new AcDream.App.UI.ItemInteractionController( Objects, playerGuid: () => _playerServerGuid, - sendUse: g => _liveSession?.SendUse(g), + // ItemHolder::UseObject is the common policy owner, but world + // activation still has to pass through the application-level + // use adapter. It owns speculative turn/move, the close-range + // deferred send, and ACE's far-range MoveTo callback. Calling + // WorldSession directly here made keyboard R approach a corpse + // without completing the same open transaction as double-click. + sendUse: SendUse, sendExamine: g => _liveSession?.SendAppraise(g), sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), @@ -2144,8 +2156,19 @@ public sealed class GameWindow : IDisposable playerOnGround: GetDebugPlayerOnGround, inNonCombatMode: () => Combat.CurrentMode == AcDream.Core.Combat.CombatMode.NonCombat, + combatState: Combat, + sendChangeCombatMode: mode => + _liveSession?.SendChangeCombatMode(mode), isComponentPack: magicCatalog.IsComponentPack, placeInBackpack: SendPickUp, + backpackContainerId: () => + _retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId + ?? _playerServerGuid, + // Retail ItemHolder::DetermineUseResult treats children of the + // current ground object as loot. Keep this late-bound because + // ViewContents can replace/close the external container while + // the retained controller remains alive. + groundObjectId: () => _externalContainers.CurrentContainerId, sendSplitToWorld: (item, amount) => _liveSession?.SendStackableSplitTo3D(item, amount), selectedObjectId: () => _selection.SelectedObjectId ?? 0u, @@ -13258,9 +13281,6 @@ public sealed class GameWindow : IDisposable return; } - if (IsExternalContainerTarget(guid)) - _externalContainers.RequestOpen(guid); - // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject // through the player's MoveToManager so close-range Use rotates the // body to face before the action fires. For FAR targets, ACE's @@ -13278,7 +13298,7 @@ public sealed class GameWindow : IDisposable { // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction // will fire it after rotation completes. - _pendingPostArrivalAction = (guid, false); + _pendingPostArrivalAction = new PendingPostArrivalAction(guid, IsPickup: false); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); return; @@ -13318,6 +13338,9 @@ public sealed class GameWindow : IDisposable } private void SendPickUp(uint itemGuid) + => SendPickUp(itemGuid, _playerServerGuid, placement: 0); + + private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) { if (_liveSession is null || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) @@ -13362,7 +13385,11 @@ public sealed class GameWindow : IDisposable if (closeRange) { - _pendingPostArrivalAction = (itemGuid, true); + _pendingPostArrivalAction = new PendingPostArrivalAction( + itemGuid, + IsPickup: true, + destinationContainerId, + placement); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}"); return; @@ -13375,9 +13402,9 @@ public sealed class GameWindow : IDisposable // ACE's chain alive. var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, itemGuid, _playerServerGuid, placement: 0); + seq, itemGuid, destinationContainerId, placement); _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}"); + Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={seq}"); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { string label = DescribeLiveEntity(itemGuid); @@ -13398,7 +13425,7 @@ public sealed class GameWindow : IDisposable /// private void OnAutoWalkArrivedSendDeferredAction() { - if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending) + if (_pendingPostArrivalAction is not { } pending) return; _pendingPostArrivalAction = null; @@ -13407,18 +13434,21 @@ public sealed class GameWindow : IDisposable return; var seq = _liveSession.NextGameActionSequence(); - if (isPickup) + if (pending.IsPickup) { var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, guid, _playerServerGuid, placement: 0); + seq, + pending.Guid, + pending.DestinationContainerId, + pending.Placement); _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}"); + Console.WriteLine($"[B.5] pickup-deferred item=0x{pending.Guid:X8} container=0x{pending.DestinationContainerId:X8} placement={pending.Placement} seq={seq}"); } else { - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); + var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, pending.Guid); _liveSession.SendGameAction(body); - Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}"); + Console.WriteLine($"[B.4b] use-deferred guid=0x{pending.Guid:X8} seq={seq}"); } } @@ -13497,20 +13527,6 @@ public sealed class GameWindow : IDisposable return dx * dx + dy * dy <= useRadius * useRadius; } - private bool IsExternalContainerTarget(uint guid) - { - if (Objects.Get(guid) is { } item - && (item.ContainerTypeHint != 0u - || item.Type.HasFlag(AcDream.Core.Items.ItemType.Container) - || item.ItemsCapacity > 0)) - { - return true; - } - - return LastSpawns.TryGetValue(guid, out var spawn) - && (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u; - } - private void InstallSpeculativeTurnToTarget(uint targetGuid) { if (_playerController is not { } pc || pc.MoveTo is null) return; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index eb725958..25acef48 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -1,4 +1,5 @@ using System; +using AcDream.Core.Combat; using AcDream.Core.Items; namespace AcDream.App.UI; @@ -63,8 +64,11 @@ internal sealed class AutoWieldController : IDisposable private readonly Action? _sendPutItemInContainer; private readonly Action? _toast; private readonly Action? _systemMessage; + private readonly CombatState? _combatState; + private readonly Action? _sendChangeCombatMode; private PendingSwitch? _pendingSwitch; + private PendingCombatSettlement? _pendingCombatSettlement; private bool _disposed; public bool IsBusy => _pendingSwitch is not null; @@ -75,7 +79,9 @@ internal sealed class AutoWieldController : IDisposable Action? sendWield, Action? sendPutItemInContainer, Action? toast, - Action? systemMessage = null) + Action? systemMessage = null, + CombatState? combatState = null, + Action? sendChangeCombatMode = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -83,12 +89,16 @@ internal sealed class AutoWieldController : IDisposable _sendPutItemInContainer = sendPutItemInContainer; _toast = toast; _systemMessage = systemMessage; + _combatState = combatState; + _sendChangeCombatMode = sendChangeCombatMode; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; _objects.MoveRequestFailed += OnMoveRequestFailed; _objects.WieldConfirmed += OnWieldConfirmed; _objects.Cleared += OnObjectsCleared; + if (_combatState is not null) + _combatState.CombatModeChanged += OnCombatModeChanged; } /// @@ -97,7 +107,10 @@ internal sealed class AutoWieldController : IDisposable /// as retail's inventory-ready gate prevents a second concurrent request. /// public bool TryWield(ClientObject item) - => TryWield(item, EquipMask.None); + { + _pendingCombatSettlement = null; + return TryWield(item, EquipMask.None, combatModeAfterWield: null); + } /// /// Execute AutoWield while retaining the paperdoll's resolved target side. @@ -106,6 +119,15 @@ internal sealed class AutoWieldController : IDisposable /// bypassing the transaction with a direct wire request. /// public bool TryWield(ClientObject item, EquipMask requestedMask) + { + _pendingCombatSettlement = null; + return TryWield(item, requestedMask, combatModeAfterWield: null); + } + + private bool TryWield( + ClientObject item, + EquipMask requestedMask, + CombatMode? combatModeAfterWield) { ArgumentNullException.ThrowIfNull(item); @@ -130,9 +152,13 @@ internal sealed class AutoWieldController : IDisposable if (GetEquippedObjectAtLocation( requestedMask, priority: 0, item.ObjectId) is { } blocker) { - return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask); + return BeginWeaponReplacement( + item.ObjectId, + blocker, + requestedMask, + combatModeAfterWield); } - return SendWield(item, requestedMask); + return SendWield(item, requestedMask, combatModeAfterWield); } EquipMask weaponLocation = requestedMask == EquipMask.None @@ -140,10 +166,24 @@ internal sealed class AutoWieldController : IDisposable : requestedMask & WeaponReadyMask; if (weaponLocation != EquipMask.None) { + // Preserve the ready mode only when the transaction starts in + // combat. During a replacement ACE legitimately reports the + // intermediate stances used to put the old weapon away; those + // must not erase the user's original intent before the new wield + // is authoritatively confirmed. + combatModeAfterWield ??= (_combatState?.CurrentMode ?? CombatMode.NonCombat) + == CombatMode.NonCombat + ? null + : CombatModeForWeaponLocation(weaponLocation); + ClientObject? blocker = GetEquippedObjectAtLocation( WeaponReadyMask, priority: 0, item.ObjectId); if (blocker is not null) - return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation); + return BeginWeaponReplacement( + item.ObjectId, + blocker, + weaponLocation, + combatModeAfterWield); // Retail checks secondary blockers only after the primary weapon // slot is vacant, then re-enters AutoWield after each confirmed @@ -151,15 +191,23 @@ internal sealed class AutoWieldController : IDisposable if (BlocksUseOfShield(item) && GetEquippedObjectAtLocation( EquipMask.Shield, priority: 0, item.ObjectId) is { } shield) - return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation); + return BeginWeaponReplacement( + item.ObjectId, + shield, + weaponLocation, + combatModeAfterWield); if (item.AmmoType is > 0 && GetEquippedObjectAtLocation( EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo && ammo.AmmoType != item.AmmoType) - return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation); + return BeginWeaponReplacement( + item.ObjectId, + ammo, + weaponLocation, + combatModeAfterWield); - return SendWield(item, weaponLocation); + return SendWield(item, weaponLocation, combatModeAfterWield); } EquipMask mask = requestedMask != EquipMask.None @@ -171,13 +219,14 @@ internal sealed class AutoWieldController : IDisposable return false; } - return SendWield(item, mask); + return SendWield(item, mask, combatModeAfterWield: null); } private bool BeginWeaponReplacement( uint requestedItemId, ClientObject blockingItem, - EquipMask requestedMask) + EquipMask requestedMask, + CombatMode? combatModeAfterWield) { if (_sendPutItemInContainer is null) { @@ -192,7 +241,8 @@ internal sealed class AutoWieldController : IDisposable _pendingSwitch = new PendingSwitch( requestedItemId, blockingItem.ObjectId, - requestedMask); + requestedMask, + combatModeAfterWield); // Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0). // Do not change the local equip projection yet: the server's move event @@ -203,14 +253,18 @@ internal sealed class AutoWieldController : IDisposable return true; } - private bool SendWield(ClientObject item, EquipMask mask) + private bool SendWield( + ClientObject item, + EquipMask mask, + CombatMode? combatModeAfterWield) { if (_sendWield is null) return false; _pendingSwitch = new PendingSwitch( item.ObjectId, BlockingItemId: 0, - RequestedMask: mask); + RequestedMask: mask, + CombatModeAfterWield: combatModeAfterWield); if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) { _pendingSwitch = null; @@ -235,14 +289,75 @@ internal sealed class AutoWieldController : IDisposable // vacant ready slot and send GetAndWieldItem for the requested weapon. _pendingSwitch = null; if (_objects.Get(pending.RequestedItemId) is { } requested) - TryWield(requested, pending.RequestedMask); + TryWield( + requested, + pending.RequestedMask, + pending.CombatModeAfterWield); } private void OnWieldConfirmed(uint itemId) { if (_pendingSwitch is { BlockingItemId: 0 } pending && itemId == pending.RequestedItemId) + { _pendingSwitch = null; + if (pending.CombatModeAfterWield is { } readyMode) + { + // ACE publishes its queued dequip tail after WieldObject. Wait + // for that first authoritative post-wield mode notice before + // responding; sending inside WieldConfirmed races the tail. + _pendingCombatSettlement = new( + itemId, + readyMode, + CombatSettlementPhase.AwaitingPostWieldMode); + } + } + } + + private void OnCombatModeChanged(CombatMode mode) + { + if (_pendingCombatSettlement is not { } settlement) + return; + if (mode == settlement.ReadyMode) + { + if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace) + { + // ACE selected the correct weapon stance, but its queued + // dequip callback still has one trailing Peace notice. + _pendingCombatSettlement = settlement with + { + Phase = CombatSettlementPhase.SawReadyAfterPeace, + }; + } + else + { + // A server that immediately settled on the intended ready + // stance needs no compatibility request. + _pendingCombatSettlement = null; + } + return; + } + + if (mode != CombatMode.NonCombat) + return; + + if (settlement.Phase == CombatSettlementPhase.SawReadyAfterPeace) + { + // This is ACE's trailing Peace after it already selected the new + // weapon stance. The server's stance shuffle is now settled, so a + // normal request from this notice is accepted and ordered after it. + _pendingCombatSettlement = null; + _sendChangeCombatMode?.Invoke(settlement.ReadyMode); + } + else + { + // First transitional Peace. ACE is still shuffling the old weapon; + // requesting here races the later queued callback. + _pendingCombatSettlement = settlement with + { + Phase = CombatSettlementPhase.SawTransitionalPeace, + }; + } } private void OnMoveRequestFailed(MoveRequestFailure failure) @@ -259,10 +374,16 @@ internal sealed class AutoWieldController : IDisposable && (item.ObjectId == pending.BlockingItemId || item.ObjectId == pending.RequestedItemId)) _pendingSwitch = null; + if (_pendingCombatSettlement is { } settlement + && item.ObjectId == settlement.ItemId) + _pendingCombatSettlement = null; } private void OnObjectsCleared() - => _pendingSwitch = null; + { + _pendingSwitch = null; + _pendingCombatSettlement = null; + } private EquipMask BestAvailableEquipMask(ClientObject item) { @@ -357,21 +478,48 @@ internal sealed class AutoWieldController : IDisposable || item.Type.HasFlag(ItemType.Caster); } + private static CombatMode? CombatModeForWeaponLocation(EquipMask location) + { + if ((location & EquipMask.MissileWeapon) != 0) + return CombatMode.Missile; + if ((location & EquipMask.Held) != 0) + return CombatMode.Magic; + if ((location & (EquipMask.MeleeWeapon | EquipMask.TwoHanded)) != 0) + return CombatMode.Melee; + return null; + } + public void Dispose() { if (_disposed) return; _disposed = true; _pendingSwitch = null; + _pendingCombatSettlement = null; _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectRemoved; _objects.MoveRequestFailed -= OnMoveRequestFailed; _objects.WieldConfirmed -= OnWieldConfirmed; _objects.Cleared -= OnObjectsCleared; + if (_combatState is not null) + _combatState.CombatModeChanged -= OnCombatModeChanged; } private readonly record struct PendingSwitch( uint RequestedItemId, uint BlockingItemId, - EquipMask RequestedMask); + EquipMask RequestedMask, + CombatMode? CombatModeAfterWield); + + private readonly record struct PendingCombatSettlement( + uint ItemId, + CombatMode ReadyMode, + CombatSettlementPhase Phase); + + private enum CombatSettlementPhase + { + AwaitingPostWieldMode, + SawTransitionalPeace, + SawReadyAfterPeace, + } } diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 11a6b93d..82ba13f0 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -1,4 +1,5 @@ using System; +using AcDream.Core.Combat; using AcDream.Core.Items; namespace AcDream.App.UI; @@ -40,7 +41,8 @@ public sealed class ItemInteractionController : IDisposable private readonly Func _playerOnGround; private readonly Func _inNonCombatMode; private readonly Func _isComponentPack; - private readonly Action? _placeInBackpack; + private readonly Action? _placeInBackpack; + private readonly Func _backpackContainerId; private readonly Action? _requestExternalContainer; private readonly Action? _auxiliaryAction; private readonly InteractionState _interactionState; @@ -73,7 +75,8 @@ public sealed class ItemInteractionController : IDisposable Func? playerOnGround = null, Func? inNonCombatMode = null, Func? isComponentPack = null, - Action? placeInBackpack = null, + Action? placeInBackpack = null, + Func? backpackContainerId = null, Action? auxiliaryAction = null, Action? sendSplitToWorld = null, Func? selectedObjectId = null, @@ -83,7 +86,9 @@ public sealed class ItemInteractionController : IDisposable Func? dragOnPlayerOpensSecureTrade = null, Action? systemMessage = null, Action? sendSplitToContainer = null, - Action? requestExternalContainer = null) + Action? requestExternalContainer = null, + CombatState? combatState = null, + Action? sendChangeCombatMode = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -105,6 +110,7 @@ public sealed class ItemInteractionController : IDisposable _inNonCombatMode = inNonCombatMode ?? (() => false); _isComponentPack = isComponentPack ?? (_ => false); _placeInBackpack = placeInBackpack; + _backpackContainerId = backpackContainerId ?? _playerGuid; _requestExternalContainer = requestExternalContainer; _auxiliaryAction = auxiliaryAction; _selectedObjectId = selectedObjectId ?? (() => 0u); @@ -119,11 +125,20 @@ public sealed class ItemInteractionController : IDisposable _sendWield, sendPutItemInContainer, _toast, - _systemMessage); + _systemMessage, + combatState, + sendChangeCombatMode); } public event Action? StateChanged; + /// + /// Retail CM_Item::SendNotice_ShowPendingInPlayer: the inventory + /// panel inserts a waiting projection before the pickup request is sent. + /// The destination and placement are the same values sent on the wire. + /// + public event Action? PendingBackpackPlacementRequested; + public uint PlayerGuid => _playerGuid(); public InteractionState InteractionState => _interactionState; @@ -399,8 +414,18 @@ public sealed class ItemInteractionController : IDisposable switch (action.Kind) { case ItemPolicyActionKind.PlaceInBackpack: - _placeInBackpack?.Invoke(action.ObjectId); - acted |= _placeInBackpack is not null; + if (_placeInBackpack is null) + break; + uint containerId = _backpackContainerId(); + if (containerId == 0u) + containerId = _playerGuid(); + const int placement = 0; + PendingBackpackPlacementRequested?.Invoke( + action.ObjectId, + containerId, + placement); + _placeInBackpack(action.ObjectId, containerId, placement); + acted = true; break; case ItemPolicyActionKind.WieldRight: case ItemPolicyActionKind.WieldLeft: diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index a6927778..7fc5ddf5 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -246,6 +246,18 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private void OnExternalContainerChanged(ExternalContainerTransition transition) { + if (transition.Kind == ExternalContainerTransitionKind.ReplacementRequested) + { + // Retail SetGroundObject(new) first publishes SetGroundObject(0) + // to the panel and only repopulates it when the new root's + // ViewContents is accepted. + _openContainer = 0u; + _closeRequested = false; + _window.Hide(); + ClearLists(); + return; + } + if (transition.ContainerId == 0u) { _openContainer = 0u; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 626e0ecc..42b4397c 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -180,7 +180,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; if (_itemInteraction is not null) + { _itemInteraction.StateChanged += OnInteractionStateChanged; + _itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested; + } Populate(); } @@ -269,6 +272,26 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnInteractionStateChanged() => ApplyIndicators(); + private void OnPendingBackpackPlacementRequested( + uint itemId, + uint containerId, + int placement) + { + if (_pendingListPlacement is not null + || itemId == 0u + || containerId != EffectiveOpen() + || _objects.Get(itemId) is not { } item + || IsBag(item)) + { + return; + } + + _pendingListPlacement = new PendingListPlacement( + itemId, + containerId, + placement); + Populate(); + } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnMoveRequestFailed(MoveRequestFailure failure) { @@ -411,6 +434,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); + /// The owned destination retail PlaceInBackpack currently uses. + public uint CurrentOpenContainerId => EffectiveOpen(); + /// Add a populated cell wired to its click role: container cell → open+select, /// item cell → select-only. private void AddCell(UiItemList? list, uint guid, bool isContainer, bool waiting = false) @@ -785,6 +811,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; if (_itemInteraction is not null) + { _itemInteraction.StateChanged -= OnInteractionStateChanged; + _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; + } } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 02755703..e5830082 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -274,6 +274,7 @@ public sealed class RetailUiRuntime : IDisposable public SelectedObjectController? SelectedObjectController { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; } + public InventoryController? InventoryPanelController { get; private set; } public RetailDialogFactory? DialogFactory { get; private set; } public ExternalContainerController? ExternalContainerController { get; private set; } @@ -1603,6 +1604,7 @@ public sealed class RetailUiRuntime : IDisposable () => CloseWindow(WindowNames.Inventory), StackSplitQuantity, b.ResolveDragIcon); + InventoryPanelController = inventory; PaperdollController paperdoll = PaperdollController.Bind( layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, contents, _bindings.Assets.DefaultFont, paperdollClickMap, diff --git a/src/AcDream.App/World/ExternalContainerLifecycleController.cs b/src/AcDream.App/World/ExternalContainerLifecycleController.cs index bd526db2..fdb06675 100644 --- a/src/AcDream.App/World/ExternalContainerLifecycleController.cs +++ b/src/AcDream.App/World/ExternalContainerLifecycleController.cs @@ -33,9 +33,11 @@ public sealed class ExternalContainerLifecycleController : IDisposable if (transition.PreviousContainerId != 0u) _objects.StopViewingContentsTree(transition.PreviousContainerId); - // Only replacement emits Event_NoLongerViewingContents. The authored - // close/range path instead sends Use(root) and waits for event 0x0052. - if (transition.Kind == ExternalContainerTransitionKind.Replaced + // SetGroundObject(new, notifyServer:true) emits this while the new + // request is still pending, not after ViewContents arrives. The + // authored close/range path instead sends Use(root) and waits for + // event 0x0052. + if (transition.Kind == ExternalContainerTransitionKind.ReplacementRequested && transition.PreviousContainerId != 0u) { _sendNoLongerViewingContents(transition.PreviousContainerId); diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index eb6ec4e7..72b033a9 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -5,7 +5,7 @@ namespace AcDream.Core.Items; public enum ExternalContainerTransitionKind { Opened, - Replaced, + ReplacementRequested, Closed, Reset, } @@ -33,7 +33,24 @@ public sealed class ExternalContainerState { if (containerId == 0u || RequestedContainerId == containerId) return false; + + uint previous = CurrentContainerId; RequestedContainerId = containerId; + + // ClientUISystem::SetGroundObject @ 0x00564510 retires the old + // ground-object presentation synchronously when a different root is + // requested. ViewContents for the new root arrives later. Keeping the + // old root current during that interval lets its range watcher issue a + // close Use, which ACE interprets as cancellation of the new root's + // MoveTo chain. + if (previous != 0u && previous != containerId) + { + CurrentContainerId = 0u; + Changed?.Invoke(new ExternalContainerTransition( + ExternalContainerTransitionKind.ReplacementRequested, + previous, + containerId)); + } return true; } @@ -49,9 +66,7 @@ public sealed class ExternalContainerState return false; Changed?.Invoke(new ExternalContainerTransition( - previous == 0u - ? ExternalContainerTransitionKind.Opened - : ExternalContainerTransitionKind.Replaced, + ExternalContainerTransitionKind.Opened, previous, containerId)); return true; diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index e3ddf361..9a142cc4 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1,4 +1,5 @@ using AcDream.App.UI; +using AcDream.Core.Combat; using AcDream.Core.Items; namespace AcDream.App.Tests.UI; @@ -21,11 +22,14 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new(); public readonly List ExternalRequests = new(); + public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new(); public readonly List Drops = new(); public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List<(uint Target, uint Item, uint Amount)> Gives = new(); public readonly List Toasts = new(); public readonly List SystemMessages = new(); + public readonly List CombatModeRequests = new(); + public readonly CombatState Combat = new(); public readonly StackSplitQuantityState SplitQuantity = new(); public uint SelectedObject; public bool NonCombatMode; @@ -65,6 +69,8 @@ public sealed class ItemInteractionControllerTests stackSplitQuantity: SplitQuantity, inNonCombatMode: () => NonCombatMode, groundObjectId: () => GroundObject, + placeInBackpack: (item, container, placement) => + BackpackPlacements.Add((item, container, placement)), sendPutItemInContainer: (item, container, placement) => Puts.Add((item, container, placement)), sendGive: (target, item, amount) => Gives.Add((target, item, amount)), @@ -76,7 +82,9 @@ public sealed class ItemInteractionControllerTests { GroundObject = id; ExternalRequests.Add(id); - }); + }, + combatState: Combat, + sendChangeCombatMode: CombatModeRequests.Add); } public ItemInteractionController Controller { get; } @@ -158,6 +166,38 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Puts); } + [Fact] + public void DoubleClickItemInOpenCorpse_placesThatItemInBackpack() + { + var h = new Harness(); + const uint corpse = 0x70000012u; + const uint loot = 0x70000013u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = corpse, + Type = ItemType.Container, + ItemsCapacity = 24, + PublicWeenieBitfield = (uint)( + PublicWeenieFlags.Openable + | PublicWeenieFlags.Stuck + | PublicWeenieFlags.Corpse), + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = loot, + Name = "Loot", + Type = ItemType.Misc, + }); + h.Objects.MoveItem(loot, corpse, 0); + h.GroundObject = corpse; + + Assert.True(h.Controller.ActivateItem(loot)); + + Assert.Equal(new[] { (loot, Player, 0) }, h.BackpackPlacements); + Assert.Empty(h.Uses); + Assert.Empty(h.Wields); + } + [Fact] public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove() { @@ -548,6 +588,107 @@ public sealed class ItemInteractionControllerTests Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); } + [Theory] + [InlineData(CombatMode.NonCombat, false)] + [InlineData(CombatMode.Magic, true)] + public void WandToBow_reassertsMissileModeOnlyForActiveCombatTransaction( + CombatMode startingMode, + bool expectsMissileMode) + { + var h = new Harness(); + h.Combat.SetCombatMode(startingMode); + const uint wand = 0x50000B91u; + const uint bow = 0x50000B92u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = wand, + Name = "Training Wand", + Type = ItemType.Caster, + CombatUse = 2, + ValidLocations = EquipMask.Held, + }); + h.Objects.MoveItem(wand, Player, -1, EquipMask.Held); + h.AddContained(bow, item => + { + item.Name = "Shortbow"; + item.Type = ItemType.MissileWeapon; + item.CombatUse = 2; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + Assert.Equal(new[] { (wand, Player, 0) }, h.Puts); + + // ACE reports several transitional combat modes while putting the + // wand away. The transaction retains the initial active-combat intent + // rather than consulting this intermediate state on its second pass. + h.Combat.SetCombatMode(CombatMode.Melee); + h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + Assert.Empty(h.CombatModeRequests); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + bow, + Player, + EquipMask.MissileWeapon)); + Assert.Empty(h.CombatModeRequests); + + // The first authoritative post-wield mode is the transaction's server + // settlement stream. Local ACE publishes Peace -> Missile -> Peace; + // only the trailing Peace is late enough to accept the reassertion. + if (expectsMissileMode) + { + h.Combat.SetCombatMode(CombatMode.NonCombat); + Assert.Empty(h.CombatModeRequests); + h.Combat.SetCombatMode(CombatMode.Missile); + Assert.Empty(h.CombatModeRequests); + h.Combat.SetCombatMode(CombatMode.NonCombat); + } + else + { + h.Combat.SetCombatMode(CombatMode.Missile); + } + + if (expectsMissileMode) + Assert.Equal(new[] { CombatMode.Missile }, h.CombatModeRequests); + else + Assert.Empty(h.CombatModeRequests); + } + + [Fact] + public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace() + { + var h = new Harness(); + h.Combat.SetCombatMode(CombatMode.Magic); + const uint wand = 0x50000BA1u; + const uint bow = 0x50000BA2u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = wand, + Type = ItemType.Caster, + CombatUse = 2, + ValidLocations = EquipMask.Held, + }); + h.Objects.MoveItem(wand, Player, -1, EquipMask.Held); + h.AddContained(bow, item => + { + item.Type = ItemType.MissileWeapon; + item.CombatUse = 2; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + h.Combat.SetCombatMode(CombatMode.Melee); + h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerWield( + bow, Player, EquipMask.MissileWeapon)); + + h.Combat.SetCombatMode(CombatMode.Missile); + h.Combat.SetCombatMode(CombatMode.NonCombat); + + Assert.Empty(h.CombatModeRequests); + } + [Fact] public void PreviouslyWieldedBow_AfterAuthoritativeUnwield_DoubleClickWieldsAgain() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index b1f7cea2..993a084c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -111,7 +111,7 @@ public sealed class ExternalContainerControllerTests sendWield: null, sendDrop: null, groundObjectId: () => State.CurrentContainerId, - placeInBackpack: Pickups.Add, + placeInBackpack: (item, _, _) => Pickups.Add(item), sendPutItemInContainer: (item, container, placement) => Puts.Add((item, container, placement)), sendSplitToContainer: (item, container, placement, amount) => @@ -213,6 +213,35 @@ public sealed class ExternalContainerControllerTests Assert.True(h.Window.IsVisible); } + [Fact] + public void ReplacementRequest_HidesOldViewWithoutSendingRangeCloseUse() + { + using var h = new Harness(); + const uint replacement = 0x70000002u; + h.Open(Chest); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = replacement, + Type = ItemType.Container, + ItemsCapacity = 24, + Useability = ItemUseability.Remote, + }); + + h.State.RequestOpen(replacement); + h.InRange = false; + h.Controller.Tick(); + + Assert.Equal(0u, h.State.CurrentContainerId); + Assert.Equal(replacement, h.State.RequestedContainerId); + Assert.False(h.Window.IsVisible); + Assert.Empty(h.Uses); + Assert.Equal(new uint[] { Chest }, h.NoLonger); + + h.State.ApplyViewContents(replacement); + Assert.Equal(replacement, h.State.CurrentContainerId); + Assert.True(h.Window.IsVisible); + } + [Fact] public void DropOwnedPartialStackIntoChest_UsesSplitRequestWithoutLocalMove() { diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index cedcedd2..94734057 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -663,6 +663,49 @@ public class InventoryControllerTests Assert.Equal(Player, objects.Get(loot)!.ContainerId); } + [Fact] + public void DoubleClickLoot_ReservesFirstOpenPackSlotBeforePickupIsSent() + { + const uint chest = 0x70000011u; + const uint loot = 0x70000012u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xAu, Player, slot: 0); + SeedContained(objects, 0xBu, Player, slot: 1); + SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); + var pickups = new List<(uint item, uint container, int placement)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1_000, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: (item, container, placement) => + pickups.Add((item, container, placement))); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.ActivateItem(loot)); + + Assert.Equal(new[] { (loot, Player, 0) }, pickups); + Assert.Equal(chest, objects.Get(loot)!.ContainerId); + Assert.Equal(loot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.Equal(0xAu, grid.GetItem(1)!.ItemId); + Assert.Equal(0xBu, grid.GetItem(2)!.ItemId); + } + [Fact] public void LootDrop_ServerFailureRemovesOnlyThePendingProjection() { diff --git a/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs b/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs index b7b66bd7..25263f9f 100644 --- a/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs +++ b/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs @@ -6,7 +6,7 @@ namespace AcDream.App.Tests.World; public sealed class ExternalContainerLifecycleControllerTests { [Fact] - public void Replacement_RetiresPreviousRootExactlyOnce() + public void ReplacementRequest_RetiresPreviousRootBeforeViewContents() { var state = new ExternalContainerState(); var objects = new ClientObjectTable(); @@ -18,6 +18,10 @@ public sealed class ExternalContainerLifecycleControllerTests state.RequestOpen(10u); state.ApplyViewContents(10u); state.RequestOpen(20u); + + Assert.Equal(new uint[] { 10u }, retired); + Assert.Empty(objects.GetContents(10u)); + state.ApplyViewContents(20u); state.ApplyViewContents(20u); diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs index 9af8d2fb..1fb7cdfb 100644 --- a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -17,7 +17,7 @@ public sealed class ExternalContainerStateTests } [Fact] - public void ReplacementPublishesOldAndNewExactlyOnce() + public void ReplacementRetiresOldImmediatelyThenOpensNewOnViewContents() { var state = new ExternalContainerState(); var changes = new List(); @@ -25,15 +25,23 @@ public sealed class ExternalContainerStateTests state.RequestOpen(1u); state.ApplyViewContents(1u); state.RequestOpen(2u); + + Assert.Equal(0u, state.CurrentContainerId); + Assert.Equal(2u, state.RequestedContainerId); + state.ApplyViewContents(2u); state.ApplyViewContents(2u); - Assert.Equal(2, changes.Count); + Assert.Equal(3, changes.Count); Assert.Equal(ExternalContainerTransitionKind.Opened, changes[0].Kind); Assert.Equal( new ExternalContainerTransition( - ExternalContainerTransitionKind.Replaced, 1u, 2u), + ExternalContainerTransitionKind.ReplacementRequested, 1u, 2u), changes[1]); + Assert.Equal( + new ExternalContainerTransition( + ExternalContainerTransitionKind.Opened, 0u, 2u), + changes[2]); } [Fact] @@ -49,14 +57,14 @@ public sealed class ExternalContainerStateTests } [Fact] - public void RefusedReplacementRestoresCurrentAsExpectedRoot() + public void RefusedReplacementLeavesTheRetiredRootClosed() { var state = Open(1u); state.RequestOpen(2u); Assert.True(state.ApplyUseDone(0x550u)); - Assert.Equal(1u, state.CurrentContainerId); - Assert.Equal(1u, state.RequestedContainerId); + Assert.Equal(0u, state.CurrentContainerId); + Assert.Equal(0u, state.RequestedContainerId); Assert.False(state.ApplyViewContents(2u)); }