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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 18:35:29 +02:00
parent d51a0fc825
commit 0392c6d721
17 changed files with 631 additions and 81 deletions

View file

@ -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.
---

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
/// </summary>
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;

View file

@ -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<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _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<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null)
Action<string>? systemMessage = null,
CombatState? combatState = null,
Action<CombatMode>? 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;
}
/// <summary>
@ -97,7 +107,10 @@ internal sealed class AutoWieldController : IDisposable
/// as retail's inventory-ready gate prevents a second concurrent request.
/// </summary>
public bool TryWield(ClientObject item)
=> TryWield(item, EquipMask.None);
{
_pendingCombatSettlement = null;
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
}
/// <summary>
/// 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.
/// </summary>
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,
}
}

View file

@ -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<bool> _playerOnGround;
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint, uint, int>? _placeInBackpack;
private readonly Func<uint> _backpackContainerId;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
@ -73,7 +75,8 @@ public sealed class ItemInteractionController : IDisposable
Func<bool>? playerOnGround = null,
Func<bool>? inNonCombatMode = null,
Func<uint, bool>? isComponentPack = null,
Action<uint>? placeInBackpack = null,
Action<uint, uint, int>? placeInBackpack = null,
Func<uint>? backpackContainerId = null,
Action<ItemPolicyAction>? auxiliaryAction = null,
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
@ -83,7 +86,9 @@ public sealed class ItemInteractionController : IDisposable
Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null)
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? 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;
/// <summary>
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: 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.
/// </summary>
public event Action<uint, uint, int>? 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:

View file

@ -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;

View file

@ -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();
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
public uint CurrentOpenContainerId => EffectiveOpen();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
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;
}
}
}

View file

@ -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,

View file

@ -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);

View file

@ -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;

View file

@ -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<uint> ExternalRequests = new();
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
public readonly List<uint> 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<string> Toasts = new();
public readonly List<string> SystemMessages = new();
public readonly List<CombatMode> 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()
{

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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);

View file

@ -17,7 +17,7 @@ public sealed class ExternalContainerStateTests
}
[Fact]
public void ReplacementPublishesOldAndNewExactlyOnce()
public void ReplacementRetiresOldImmediatelyThenOpensNewOnViewContents()
{
var state = new ExternalContainerState();
var changes = new List<ExternalContainerTransition>();
@ -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));
}