diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a1c6c21b..b51c6997 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50). | IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | -| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | +| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | --- @@ -73,7 +73,7 @@ accepted-divergence entries (#96, #49, #50). | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | -| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 | +| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow null for the established creature/large-object fallback while preserving explicit zero/one rejection | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsUseable`) | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally leaves without useability can become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923; `ItemUses::IsUseable @ 0x004FCCC0` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | @@ -87,7 +87,7 @@ accepted-divergence entries (#96, #49, #50). | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | -| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | +| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | @@ -132,7 +132,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | -| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | +| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | diff --git a/src/AcDream.App/Input/OutboundInteractionQueue.cs b/src/AcDream.App/Input/OutboundInteractionQueue.cs index a23e12ca..0c50f248 100644 --- a/src/AcDream.App/Input/OutboundInteractionQueue.cs +++ b/src/AcDream.App/Input/OutboundInteractionQueue.cs @@ -16,6 +16,7 @@ namespace AcDream.App.Input; public sealed class OutboundInteractionQueue { private readonly Queue _pending = new(); + private uint _clearEpoch; public int Count => _pending.Count; @@ -33,9 +34,14 @@ public sealed class OutboundInteractionQueue public void Drain() { int count = _pending.Count; - while (count-- > 0) + uint epoch = _clearEpoch; + while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0) _pending.Dequeue().Invoke(); } - public void Clear() => _pending.Clear(); + public void Clear() + { + _pending.Clear(); + _clearEpoch++; + } } diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index 2890aedb..7c8cfb50 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -6,7 +6,13 @@ namespace AcDream.App.Interaction; internal interface IPlayerInteractionMovementSink { - bool BeginApproach(InteractionApproach approach); + /// + /// Cancels any preceding MoveTo, invokes , + /// then installs the new movement. The callback boundary lets the intent + /// owner arm completion state before an already-facing TurnTo can finish + /// synchronously, without letting the preceding cancellation clear it. + /// + bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null); } /// @@ -20,7 +26,7 @@ internal sealed class PlayerInteractionMovementSink( private readonly Func _player = player ?? throw new ArgumentNullException(nameof(player)); - public bool BeginApproach(InteractionApproach approach) + public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) { PlayerMovementController? controller = _player(); if (controller?.MoveTo is null) @@ -47,11 +53,16 @@ internal sealed class PlayerInteractionMovementSink( Height = approach.TargetHeight, }; + // PerformMovement cancels at its head. Do it explicitly before the + // intent is armed so cancellation of the preceding move cannot clear + // the new request; the internal second call is then a retail no-op. + controller.Movement.CancelMoveTo(WeenieError.ActionCancelled); + armAfterCancel?.Invoke(); + // P1's wire MoveToObject store marks the command non-autonomous. // The speculative local install must do the same or the next raw-input // pump overwrites it before ACE's authoritative movement arrives. controller.SetLastMoveWasAutonomous(false); - controller.Movement.PerformMovement(movement); - return true; + return controller.Movement.PerformMovement(movement) == WeenieError.None; } } diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 67e37dde..430d1258 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -1,6 +1,7 @@ using AcDream.App.Input; using AcDream.App.UI; using AcDream.App.World; +using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.Ui; @@ -24,12 +25,18 @@ internal sealed class SelectionInteractionController private readonly Action? _toast; private PendingPostArrivalAction? _pendingPostArrival; + private readonly record struct QueuedInteractionIdentity( + uint ServerGuid, + uint? LocalEntityId, + ClientObject? ClientObject); + private readonly record struct PendingPostArrivalAction( uint ServerGuid, uint LocalEntityId, bool IsPickup, uint DestinationContainerId, - int Placement); + int Placement, + ulong PendingPlacementToken); public SelectionInteractionController( SelectionState selection, @@ -69,8 +76,14 @@ internal sealed class SelectionInteractionController case InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) { - _outbound.Enqueue(() => - _items.PlaceWorldItemInBackpack(pickupTarget)); + EnqueueIdentityBound( + pickupTarget, + requireLiveEntity: true, + guid => + { + if (ValidatePickupTarget(guid, showToast: true)) + _items.PlaceWorldItemInBackpack(guid); + }); } else { @@ -85,12 +98,18 @@ internal sealed class SelectionInteractionController } } - public uint? PickAt(float mouseX, float mouseY, bool includeSelf) - => _query.PickAt(mouseX, mouseY, includeSelf); - public uint? PickAtCursor(bool includeSelf) => _query.PickAtCursor(includeSelf); + public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY) + { + ArgumentNullException.ThrowIfNull(payload); + uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u; + if (target != 0u) + _query.BeginLightingPulse(target); + _items.PlaceIn3D(payload, target); + } + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) { if (_selection.SelectedObjectId is { } selected @@ -147,7 +166,13 @@ internal sealed class SelectionInteractionController Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); _toast?.Invoke($"Selected: {label}"); if (useImmediately) - _outbound.Enqueue(() => _items.ActivateItem(guid)); + EnqueueIdentityBound( + guid, + requireLiveEntity: true, + selected => + { + _items.ActivateItem(selected); + }); } public void UseCurrentSelection() @@ -157,11 +182,18 @@ internal sealed class SelectionInteractionController _toast?.Invoke("Nothing selected"); return; } - _outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected)); + EnqueueIdentityBound( + selected, + requireLiveEntity: false, + guid => + { + _items.UseSelectedOrEnterMode(guid); + }); } public void SendUse(uint serverGuid) { + CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); @@ -173,63 +205,104 @@ internal sealed class SelectionInteractionController return; } - _movement.BeginApproach(approach); if (approach.IsCloseRange) { - _pendingPostArrival = new PendingPostArrivalAction( + var pending = new PendingPostArrivalAction( serverGuid, approach.Target.LocalEntityId, IsPickup: false, DestinationContainerId: 0u, - Placement: 0); + Placement: 0, + PendingPlacementToken: 0u); + bool started = _movement.BeginApproach( + approach, + () => _pendingPostArrival = pending); + if (!started && _pendingPostArrival == pending) + CancelPendingApproach(); return; } + _movement.BeginApproach(approach); if (_transport.TrySendUse(serverGuid, out uint sequence)) Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); } public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) { + CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); + CancelPickupPresentation(itemGuid); return; } - if (_query.IsCreature(itemGuid)) + if (!ValidatePickupTarget(itemGuid, showToast: true) + || !_query.TryGetApproach(itemGuid, out InteractionApproach approach)) + { + CancelPickupPresentation(itemGuid); + return; + } + + ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement( + itemGuid, + out PendingBackpackPlacement pendingPlacement) + ? pendingPlacement.Token + : 0u; + if (!IsCurrentPickupPresentation( + itemGuid, + destinationContainerId, + placement, + pendingPlacementToken)) { - _toast?.Invoke(RetailMessages.CannotPickUpCreatures); return; } - if (!_query.IsPickupable(itemGuid)) - { - _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid))); - return; - } - if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach)) - return; - _movement.BeginApproach(approach); if (approach.IsCloseRange) { - _pendingPostArrival = new PendingPostArrivalAction( + var pending = new PendingPostArrivalAction( itemGuid, approach.Target.LocalEntityId, IsPickup: true, destinationContainerId, - placement); + placement, + pendingPlacementToken); + bool started = _movement.BeginApproach( + approach, + () => _pendingPostArrival = pending); + if (!started && _pendingPostArrival == pending) + CancelPendingApproach(); + else if (!started) + CancelPickupPresentation(itemGuid, pendingPlacementToken); return; } - if (_transport.TrySendPickup( + _movement.BeginApproach(approach); + if (!IsCurrentPickupPresentation( itemGuid, destinationContainerId, placement, - out uint sequence)) + pendingPlacementToken)) + { + return; + } + uint sequence = 0u; + if (_items.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + itemGuid, + () => _transport.TrySendPickup( + itemGuid, + destinationContainerId, + placement, + out sequence), + pendingPlacementToken)) { Console.WriteLine( $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); } + else + { + CancelPickupPresentation(itemGuid, pendingPlacementToken); + } } /// Fires only after natural MoveToComplete(None), never cancellation. @@ -239,15 +312,38 @@ internal sealed class SelectionInteractionController return; _pendingPostArrival = null; if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) + { + if (pending.IsPickup) + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); return; + } if (pending.IsPickup) { - _transport.TrySendPickup( - pending.ServerGuid, - pending.DestinationContainerId, - pending.Placement, - out _); + if (!IsCurrentPickupPresentation( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + pending.PendingPlacementToken)) + { + return; + } + if (!_items.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + pending.ServerGuid, + () => _transport.TrySendPickup( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + out _), + pending.PendingPlacementToken)) + { + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + } } else { @@ -257,10 +353,12 @@ internal sealed class SelectionInteractionController public void DrainOutbound() => _outbound.Drain(); + public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); + public void OnEntityHidden(uint serverGuid) { if (_pendingPostArrival?.ServerGuid == serverGuid) - _pendingPostArrival = null; + CancelPendingApproach(); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( @@ -276,7 +374,7 @@ internal sealed class SelectionInteractionController && pending.ServerGuid == record.ServerGuid && pending.LocalEntityId == record.LocalEntityId) { - _pendingPostArrival = null; + CancelPendingApproach(); } if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) { @@ -288,9 +386,85 @@ internal sealed class SelectionInteractionController public void ResetSession() { + CancelPendingApproach(); _items.ResetSession(); _selection.Reset(); _outbound.Clear(); _pendingPostArrival = null; } + + private bool ValidatePickupTarget(uint serverGuid, bool showToast) + { + if (_query.IsCreature(serverGuid)) + { + if (showToast) + _toast?.Invoke(RetailMessages.CannotPickUpCreatures); + return false; + } + if (_query.IsPickupable(serverGuid)) + return true; + if (showToast) + _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid))); + return false; + } + + private bool EnqueueIdentityBound( + uint serverGuid, + bool requireLiveEntity, + Action action) + { + ArgumentNullException.ThrowIfNull(action); + uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId) + ? localId + : null; + ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured) + ? captured + : null; + if ((requireLiveEntity && localEntityId is null) + || (localEntityId is null && item is null)) + { + return false; + } + + var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item); + _outbound.Enqueue(() => + { + if (IsCurrent(identity)) + action(identity.ServerGuid); + }); + return true; + } + + private bool IsCurrent(QueuedInteractionIdentity identity) + => (identity.LocalEntityId is not uint localId + || _query.IsCurrent(identity.ServerGuid, localId)) + && (identity.ClientObject is not { } item + || _items.IsCurrentObjectIdentity(identity.ServerGuid, item)); + + private void CancelPendingApproach() + { + if (_pendingPostArrival is not { } pending) + return; + _pendingPostArrival = null; + if (pending.IsPickup) + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + } + + private void CancelPickupPresentation(uint itemGuid, ulong token = 0u) + => _items.CancelPendingBackpackPlacement(itemGuid, token); + + private bool IsCurrentPickupPresentation( + uint itemGuid, + uint destinationContainerId, + int placement, + ulong token) + => token != 0u + && _items.TryGetPendingBackpackPlacement( + itemGuid, + out PendingBackpackPlacement pending) + && pending.Token == token + && pending.ContainerId == destinationContainerId + && pending.Placement == placement; } diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index f5548a81..05df1a4d 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -39,6 +39,7 @@ internal interface IWorldSelectionQuery uint? PickAtCursor(bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf); void BeginLightingPulse(uint serverGuid); + bool TryCaptureIdentity(uint serverGuid, out uint localEntityId); bool IsCurrent(uint serverGuid, uint localEntityId); string Describe(uint serverGuid); bool IsCreature(uint serverGuid); @@ -142,6 +143,18 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery _selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId); } + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) + { + localEntityId = target.LocalEntityId; + return true; + } + + localEntityId = 0u; + return false; + } + public bool TryGetInteractionTarget( uint serverGuid, out WorldInteractionTarget target) @@ -274,7 +287,7 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery return true; } - /// ItemUses::IsUseable @ retail 0x00566A20 call family. + /// ItemUses::IsUseable @ retail 0x004FCCC0 call family. public bool IsUseable(uint serverGuid) { if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d562c822..cef7eb2e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -13081,12 +13081,7 @@ public sealed class GameWindow : IDisposable private void OnUiDragReleasedOutside(object payload, int x, int y) { if (payload is AcDream.App.UI.ItemDragPayload itemPayload) - { - uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u; - if (target != 0u) - BeginSelectionLightingPulse(target); - _itemInteractionController?.PlaceIn3D(itemPayload, target); - } + _selectionInteractions?.PlaceDraggedItem(itemPayload, x, y); } // L.0 follow-up: persisted-settings cache populated by @@ -13746,15 +13741,8 @@ public sealed class GameWindow : IDisposable return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); } - // ============================================================ - // Phase B.4b — outbound Use handler. Wires three input actions - // (LMB click select, LMB-double-click select+use, R hotkey - // use-selected) through WorldPicker into InteractRequests.BuildUse. - // The inbound reply (SetState 0xF74B) lands via L.2g slice 1. - // ============================================================ - /// - /// Shared world pick at the current cursor. The renderer supplies the + /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs /// retail's drawing-sphere broadphase followed by flat visual-polygon /// intersection. allows item target-use to @@ -13764,13 +13752,6 @@ public sealed class GameWindow : IDisposable => _selectionInteractions?.PickAtCursor(includeSelf) ?? _worldSelectionQuery?.PickAtCursor(includeSelf); - private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) - => _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf) - ?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); - - private void BeginSelectionLightingPulse(uint serverGuid) - => _worldSelectionQuery?.BeginLightingPulse(serverGuid); - // Contained/toolbar activation is not a world-selection interaction: it // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) @@ -13818,33 +13799,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) => _worldSelectionQuery?.ResolveVividTargetInfo(guid); - - /// - /// 2026-05-16 — retail-faithful port of - /// SmartBox::GetObjectBoundingBox (decomp 0x00452e20) - /// using CPhysicsObj::GetSelectionSphere (0x0050ea40) - /// → CPartArray::GetSelectionSphere (0x00518b80). - /// - /// - /// Retail's VividTargetIndicator does NOT use a per-mesh AABB — - /// it uses the Setup's selection_sphere field (a single - /// sphere encompassing the entire entity, baked at Setup-creation - /// time). The sphere is scaled by the part-array scale - /// (component-wise on center, Z-scale on radius — retail's exact - /// formula at 0x00518ba6–0x00518be3) and rotated by entity - /// orientation. The screen indicator rect is the projection of - /// the camera-aligned BBox of this sphere — i.e. a screen circle - /// of radius worldRadius * focalLength / depth. - /// - /// - /// - /// Result: the indicator rect MATCHES the Setup's intended - /// "selectable extent" — which is typically larger than the mesh - /// AABB by design (Setups bake a slightly oversized selection - /// sphere so far targets still get pickable indicators). That's - /// why our previous mesh-AABB indicator was smaller than retail's. - /// - /// private void TogglePlayerMode() { // Phase B.2 guard: only active when a live session is in-world. @@ -14084,7 +14038,11 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"[autowalk-end] reason=complete err={err}"); if (err == AcDream.Core.Physics.WeenieError.None) OnAutoWalkArrivedSendDeferredAction(); + else + _selectionInteractions?.OnMoveToCancelled(err); }; + playerMoveTo.MoveToCancelled = err => + _selectionInteractions?.OnMoveToCancelled(err); // R5-V3 (#171, retires TS-39 — player side): bind the sticky // seams to the player host's PositionManager (same trio as the diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 45b15389..9f0c80fc 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -12,6 +12,31 @@ public enum ItemPrimaryClickResult ConsumedRejected, } +public readonly record struct PendingBackpackPlacement( + ulong Token, + uint ItemId, + uint ContainerId, + int Placement, + ClientObject? ItemIdentity); + +public enum InventoryRequestKind +{ + Pickup, + PutInContainer, + SplitToContainer, + Merge, + DropToWorld, + SplitToWorld, + Give, +} + +public readonly record struct PendingInventoryRequest( + ulong Token, + InventoryRequestKind Kind, + uint ItemId, + ClientObject? ItemIdentity, + bool Dispatched); + /// /// Shared retail item interaction orchestrator. UI widgets route item clicks, /// target acquisition, and drag-out drops here instead of duplicating @@ -20,6 +45,8 @@ public enum ItemPrimaryClickResult public sealed class ItemInteractionController : IDisposable { private const long RetailUseThrottleMs = 200; + internal const string InventoryRequestBusyMessage = + "You can only move or use one item at a time"; private const long RetailDoubleClickMs = 500; private readonly ClientObjectTable _objects; @@ -56,6 +83,9 @@ public sealed class ItemInteractionController : IDisposable private uint _consumedPrimaryClickTarget; private long _consumedPrimaryClickMs = long.MinValue / 2; private int _busyCount; + private ulong _nextInventoryRequestToken; + private PendingBackpackPlacement? _pendingBackpackPlacement; + private PendingInventoryRequest? _pendingInventoryRequest; private bool _disposed; public ItemInteractionController( @@ -119,6 +149,11 @@ public sealed class ItemInteractionController : IDisposable _systemMessage = systemMessage; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; + _objects.ObjectMoved += OnInventoryObjectMoved; + _objects.MoveRequestFailed += OnInventoryMoveFailed; + _objects.ObjectRemoved += OnInventoryObjectRemoved; + _objects.StackSizeUpdated += OnInventoryStackSizeUpdated; + _objects.Cleared += OnInventoryObjectsCleared; _autoWield = new AutoWieldController( _objects, _playerGuid, @@ -137,7 +172,20 @@ public sealed class ItemInteractionController : IDisposable /// 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 event Action? PendingBackpackPlacementRequested; + + /// + /// Withdraws a waiting pickup projection when the approach is rejected or + /// cancelled before a request reaches the server. + /// + public event Action? PendingBackpackPlacementCancelled; + + /// + /// Resolves the exact waiting projection after an authoritative response. + /// The token prevents a late response for a recycled GUID from erasing a + /// newer incarnation's projection in the inventory panel. + /// + public event Action? PendingBackpackPlacementResolved; public uint PlayerGuid => _playerGuid(); @@ -146,8 +194,151 @@ public sealed class ItemInteractionController : IDisposable public int BusyCount => _busyCount; public bool CanMakeInventoryRequest => + BaseCanMakeInventoryRequest && _pendingInventoryRequest is null; + + private bool BaseCanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy; + /// + /// Retail ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest. + /// The destination ItemList's separate m_pendingItem rejection is + /// reported by . + /// + public bool EnsureInventoryRequestReady() + { + if (CanMakeInventoryRequest) + return true; + + if (_pendingInventoryRequest is not null + || _busyCount != 0 + || _autoWield.IsBusy) + _systemMessage?.Invoke(InventoryRequestBusyMessage); + return false; + } + + /// + /// Retail UIElement_ItemList::AcceptDragObject's local + /// m_pendingItem branch. This wording belongs only to the destination + /// list which already displays the waiting projection. + /// + public void ReportPendingBackpackPlacementConflict() + { + if (_pendingBackpackPlacement is not { } pending) + return; + string? itemName = _objects.Get(pending.ItemId)?.Name; + string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName; + _systemMessage?.Invoke($"Already attempting to place {name} here"); + } + + /// + /// Dispatches one retail inventory request. A provisional global + /// ACCWeenieObject::prevRequest owner closes callback reentrancy, + /// then becomes dispatched immediately after a successful wire send. + /// The callback returns false when it could not dispatch anything. + /// Retail: RecordRequest @ 0x0058C220, UIAttempt family + /// 0x0058D590..0x0058D850. + /// + public bool TryDispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch, + ulong reservationToken = 0u) + { + ArgumentNullException.ThrowIfNull(dispatch); + if (itemId == 0u) + return false; + + if (reservationToken != 0u) + { + if (_pendingInventoryRequest is not { } reserved + || reserved.Token != reservationToken + || reserved.ItemId != itemId + || reserved.Kind != kind + || reserved.Dispatched) + { + return false; + } + bool reservedDispatch; + try + { + reservedDispatch = dispatch(); + } + catch + { + CancelPendingBackpackPlacement(itemId, reservationToken); + throw; + } + if (!reservedDispatch) + { + CancelPendingBackpackPlacement(itemId, reservationToken); + return false; + } + // A synchronous test transport or reentrant table listener may + // already have completed this exact request. Never resurrect it or + // overwrite a newer transaction acquired from that response. + if (_pendingInventoryRequest is { } reservationCurrent + && reservationCurrent.Token == reserved.Token) + { + _pendingInventoryRequest = reserved with { Dispatched = true }; + StateChanged?.Invoke(); + } + return true; + } + + if (!EnsureInventoryRequestReady()) + return false; + + ulong token = ++_nextInventoryRequestToken; + if (token == 0u) + token = ++_nextInventoryRequestToken; + var provisional = new PendingInventoryRequest( + token, + kind, + itemId, + _objects.Get(itemId), + Dispatched: false); + // Provisional ownership closes the reentrancy window around optimistic + // table mutation and arbitrary send adapters. Retail writes prevRequest + // after its synchronous event call; our callback seam can publish local + // notices reentrantly, so the owner is reserved before entering it. + _pendingInventoryRequest = provisional; + StateChanged?.Invoke(); + + bool dispatched; + try + { + dispatched = dispatch(); + } + catch + { + ClearPendingInventoryRequest(token); + throw; + } + if (!dispatched) + { + ClearPendingInventoryRequest(token); + return false; + } + if (_pendingInventoryRequest is { } current + && current.Token == token) + { + _pendingInventoryRequest = provisional with { Dispatched = true }; + StateChanged?.Invoke(); + } + return true; + } + + public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending) + { + if (_pendingInventoryRequest is { } current) + { + pending = current; + return true; + } + pending = default; + return false; + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -287,6 +478,8 @@ public sealed class ItemInteractionController : IDisposable if (!ConsumeUseThrottle()) return true; + if (!EnsureInventoryRequestReady()) + return false; var input = new ItemUsePolicyInput( Snapshot(item), @@ -317,11 +510,195 @@ public sealed class ItemInteractionController : IDisposable if (containerId == 0u) containerId = _playerGuid(); const int placement = 0; - PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement); + if (!TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + out _)) + { + // Retail permits only one inventory request at a time. Consume the + // input without replacing a pending projection or issuing a wire + // request while another inventory transaction owns the gate. + return true; + } + // This callback may install a retail MoveToObject approach instead of + // sending immediately. SelectionInteractionController promotes the + // reservation to Dispatched only when it actually emits the wire move. _placeInBackpack(itemGuid, containerId, placement); return true; } + public bool TryBeginPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + out PendingBackpackPlacement pending) + => TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + InventoryRequestKind.Pickup, + out pending); + + private bool TryBeginPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + InventoryRequestKind kind, + out PendingBackpackPlacement pending) + { + if (itemGuid == 0u || containerId == 0u) + { + pending = default; + return false; + } + + if (_pendingBackpackPlacement is { } existing) + { + ReportPendingBackpackPlacementConflict(); + pending = existing; + return false; + } + if (!EnsureInventoryRequestReady()) + { + pending = default; + return false; + } + + ulong token = ++_nextInventoryRequestToken; + if (token == 0u) + token = ++_nextInventoryRequestToken; + ClientObject? identity = _objects.Get(itemGuid); + pending = new PendingBackpackPlacement( + token, + itemGuid, + containerId, + placement, + identity); + _pendingBackpackPlacement = pending; + _pendingInventoryRequest = new PendingInventoryRequest( + token, + kind, + itemGuid, + identity, + Dispatched: false); + PendingBackpackPlacementRequested?.Invoke(pending); + if (_pendingBackpackPlacement != pending + || _pendingInventoryRequest is not { } published + || published.Token != token + || published.ItemId != itemGuid + || published.Kind != kind + || published.Dispatched) + { + return false; + } + StateChanged?.Invoke(); + return true; + } + + /// + /// Publishes retail's destination waiting projection, dispatches the move, + /// then records the global request. Failed dispatch withdraws only the + /// projection created by this call. + /// + public bool TryDispatchPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + InventoryRequestKind kind, + Func dispatch) + { + // Callers such as gmToolbarUI check the global prevRequest before they + // ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the + // generic busy wording when both states happen to be present. + if (!EnsureInventoryRequestReady()) + return false; + if (!TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + kind, + out PendingBackpackPlacement pending)) + { + return false; + } + + if (_pendingBackpackPlacement != pending + || _pendingInventoryRequest is not { } reserved + || reserved.Token != pending.Token + || reserved.ItemId != pending.ItemId + || reserved.Kind != kind + || reserved.Dispatched) + { + return false; + } + + if (TryDispatchInventoryRequest( + kind, + itemGuid, + dispatch, + pending.Token)) + return true; + + CancelPendingBackpackPlacement(itemGuid, pending.Token); + return false; + } + + public bool TryGetPendingBackpackPlacement( + uint itemGuid, + out PendingBackpackPlacement pending) + { + if (_pendingBackpackPlacement is { } current + && current.ItemId == itemGuid) + { + pending = current; + return true; + } + + pending = default; + return false; + } + + public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u) + { + if (_pendingBackpackPlacement is not { } pending + || (itemGuid != 0u && pending.ItemId != itemGuid) + || (token != 0u && pending.Token != token)) + { + return; + } + + _pendingBackpackPlacement = null; + if (_pendingInventoryRequest is { } request + && request.Token == pending.Token + && !request.Dispatched) + { + _pendingInventoryRequest = null; + StateChanged?.Invoke(); + } + PendingBackpackPlacementCancelled?.Invoke(pending); + } + + private void ClearPendingInventoryRequest(ulong token) + { + if (_pendingInventoryRequest is not { } pending + || pending.Token != token) + { + return; + } + _pendingInventoryRequest = null; + StateChanged?.Invoke(); + } + + internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item) + { + item = _objects.Get(objectId)!; + return item is not null; + } + + internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item) + => ReferenceEquals(_objects.Get(objectId), item); + /// /// Retail paperdoll drop entry point. The paperdoll resolves the exact /// target side/location; this shared interaction owner performs AutoWield's @@ -333,6 +710,8 @@ public sealed class ItemInteractionController : IDisposable return false; if (_objects.Get(itemGuid) is not { } item) return false; + if (!EnsureInventoryRequestReady()) + return false; return _autoWield.TryWield(item, targetMask); } @@ -360,7 +739,7 @@ public sealed class ItemInteractionController : IDisposable if (!compatible) return false; - if (!CanMakeInventoryRequest) + if (!EnsureInventoryRequestReady()) return false; _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _busyCount++; @@ -380,6 +759,8 @@ public sealed class ItemInteractionController : IDisposable { if (objectId == 0u || _sendUse is null) return false; + if (!EnsureInventoryRequestReady()) + return false; _sendUse(objectId); _busyCount++; return true; @@ -407,6 +788,8 @@ public sealed class ItemInteractionController : IDisposable return false; if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) return false; + if (!EnsureInventoryRequestReady()) + return false; uint fullStack = (uint)Math.Max(1, item.StackSize); int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize( @@ -496,22 +879,54 @@ public sealed class ItemInteractionController : IDisposable switch (action.Kind) { case ItemPolicyActionKind.DropToWorld: - _objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1); - _sendDrop?.Invoke(action.ObjectId); + TryDispatchInventoryRequest( + InventoryRequestKind.DropToWorld, + action.ObjectId, + () => + { + if (_sendDrop is null + || !_objects.MoveItemOptimistic( + action.ObjectId, + newContainerId: 0u, + newSlot: -1)) + { + return false; + } + _sendDrop(action.ObjectId); + return true; + }); break; case ItemPolicyActionKind.SplitToWorld: // UIAttemptSplitTo3D leaves the original object in its container; // the server assigns the split-off ground stack a new guid. - _sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount); + TryDispatchInventoryRequest( + InventoryRequestKind.SplitToWorld, + action.ObjectId, + () => + { + if (_sendSplitToWorld is null) + return false; + _sendSplitToWorld(action.ObjectId, (uint)action.Amount); + return true; + }); break; case ItemPolicyActionKind.GiveToTarget: // UIAttemptGive @ 0x0058D620 sends the request and leaves // the source untouched until the authoritative server // InventoryRemoveObject / SetStackSize response arrives. - _sendGive?.Invoke( - action.TargetId, + TryDispatchInventoryRequest( + InventoryRequestKind.Give, action.ObjectId, - (uint)Math.Max(1, action.Amount)); + () => + { + if (_sendGive is null) + return false; + _sendGive( + action.TargetId, + action.ObjectId, + (uint)Math.Max(1, action.Amount)); + return true; + }); break; case ItemPolicyActionKind.PlaceInContainer: { @@ -519,10 +934,35 @@ public sealed class ItemInteractionController : IDisposable 1, _objects.Get(action.ObjectId)?.StackSize ?? action.Amount); uint amount = (uint)Math.Max(1, action.Amount); - if (amount < fullStack) - _sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount); - else - _sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0); + InventoryRequestKind kind = amount < fullStack + ? InventoryRequestKind.SplitToContainer + : InventoryRequestKind.PutInContainer; + TryDispatchInventoryRequest( + kind, + action.ObjectId, + () => + { + if (amount < fullStack) + { + if (_sendSplitToContainer is null) + return false; + _sendSplitToContainer( + action.ObjectId, + action.TargetId, + 0u, + amount); + } + else + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer( + action.ObjectId, + action.TargetId, + 0); + } + return true; + }); break; } case ItemPolicyActionKind.Reject: @@ -571,11 +1011,93 @@ public sealed class ItemInteractionController : IDisposable private void OnInteractionModeChanged(InteractionModeTransition _) => StateChanged?.Invoke(); + private void OnInventoryObjectMoved(ClientObjectMove move) + { + if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse) + return; + + CompleteInventoryResponse(move.ItemId, move.Item); + } + + private void OnInventoryMoveFailed(MoveRequestFailure failure) + { + ClientObject? identity = _objects.Get(failure.ItemId); + CompleteInventoryResponse(failure.ItemId, identity); + } + + private void OnInventoryObjectRemoved(ClientObject item) + { + CompleteInventoryResponse(item.ObjectId, item); + } + + private void OnInventoryStackSizeUpdated(ClientObject item) + { + CompleteInventoryResponse(item.ObjectId, item); + } + + private void CompleteInventoryResponse(uint itemId, ClientObject? identity) + { + PendingInventoryRequest? completedRequest = null; + PendingBackpackPlacement? completedPlacement = null; + + if (_pendingInventoryRequest is { } request + && request.ItemId == itemId + && MatchesIdentity(request.ItemIdentity, itemId, identity)) + { + completedRequest = request; + _pendingInventoryRequest = null; + } + + if (_pendingBackpackPlacement is { } placement + && placement.ItemId == itemId + && MatchesIdentity(placement.ItemIdentity, itemId, identity)) + { + completedPlacement = placement; + _pendingBackpackPlacement = null; + } + + // ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest + // before ServerSaysMoveItem publishes the item-list notice. Clear the + // coupled global owner and local waiting projection atomically before + // either callback so observers never see a half-completed transaction. + if (completedPlacement is { } resolved) + PendingBackpackPlacementResolved?.Invoke(resolved); + if (completedRequest is not null) + StateChanged?.Invoke(); + } + + private bool MatchesIdentity( + ClientObject? expected, + uint itemId, + ClientObject? actual) + { + if (expected is null) + return true; + if (actual is not null) + return ReferenceEquals(expected, actual); + return _objects.Get(itemId) is not { } current + || ReferenceEquals(expected, current); + } + + private void OnInventoryObjectsCleared() + { + bool changed = _pendingInventoryRequest is not null; + _pendingInventoryRequest = null; + _pendingBackpackPlacement = null; + if (changed) + StateChanged?.Invoke(); + } + public void Dispose() { if (_disposed) return; _disposed = true; _interactionState.Changed -= OnInteractionModeChanged; + _objects.ObjectMoved -= OnInventoryObjectMoved; + _objects.MoveRequestFailed -= OnInventoryMoveFailed; + _objects.ObjectRemoved -= OnInventoryObjectRemoved; + _objects.StackSizeUpdated -= OnInventoryStackSizeUpdated; + _objects.Cleared -= OnInventoryObjectsCleared; _autoWield.Dispose(); } @@ -602,9 +1124,11 @@ public sealed class ItemInteractionController : IDisposable /// public void ResetSession() { - bool busyChanged = _busyCount != 0; + CancelPendingBackpackPlacement(); + bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null; bool modeChanged = IsAnyTargetModeActive; _busyCount = 0; + _pendingInventoryRequest = null; _lastUseMs = long.MinValue / 2; _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 7fc5ddf5..35371745 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -223,6 +223,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine { if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) return; + if (!_itemInteraction.EnsureInventoryRequestReady()) + return; if (_objects.Get(payload.ObjId) is not { } item) return; @@ -238,10 +240,20 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine // ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and // UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not // optimistically rewrite external ownership. - if (amount < fullStack) - _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); - else - _sendPutItemInContainer(item.ObjectId, _openContainer, placement); + InventoryRequestKind kind = amount < fullStack + ? InventoryRequestKind.SplitToContainer + : InventoryRequestKind.PutInContainer; + _itemInteraction.TryDispatchInventoryRequest( + kind, + item.ObjectId, + () => + { + if (amount < fullStack) + _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); + else + _sendPutItemInContainer(item.ObjectId, _openContainer, placement); + return true; + }); } private void OnExternalContainerChanged(ExternalContainerTransition transition) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 42b4397c..02e660a6 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -67,6 +67,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private bool _disposed; private readonly record struct PendingListPlacement( + ulong Token, uint ItemId, uint ContainerId, int Placement); @@ -183,6 +184,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { _itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled += OnPendingBackpackPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved += OnPendingBackpackPlacementResolved; } Populate(); @@ -242,7 +245,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } private void OnObjectRemoved(ClientObject o) { - if (_pendingListPlacement?.ItemId == o.ObjectId) + bool fallbackResolved = _itemInteraction is null + && _pendingListPlacement?.ItemId == o.ObjectId; + if (fallbackResolved) _pendingListPlacement = null; if (_selection.SelectedObjectId == o.ObjectId) { @@ -250,15 +255,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo SelectionChangeSource.System, SelectionChangeReason.SelectedObjectRemoved); } - if (Concerns(o)) Populate(); + if (fallbackResolved || Concerns(o)) Populate(); } private void OnObjectMoved(ClientObjectMove move) { - bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId; - if (resolvedPending) + bool fallbackResolved = _itemInteraction is null + && _pendingListPlacement?.ItemId == move.ItemId; + if (fallbackResolved) _pendingListPlacement = null; uint player = _playerGuid(); - if (resolvedPending + if (fallbackResolved || (move.Item is { } item && Concerns(item)) || move.Previous.ContainerId == player || move.Current.ContainerId == player @@ -272,30 +278,45 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnInteractionStateChanged() => ApplyIndicators(); - private void OnPendingBackpackPlacementRequested( - uint itemId, - uint containerId, - int placement) + private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) { if (_pendingListPlacement is not null - || itemId == 0u - || containerId != EffectiveOpen() - || _objects.Get(itemId) is not { } item + || pending.ItemId == 0u + || pending.ContainerId != EffectiveOpen() + || _objects.Get(pending.ItemId) is not { } item || IsBag(item)) { return; } _pendingListPlacement = new PendingListPlacement( - itemId, - containerId, - placement); + pending.Token, + pending.ItemId, + pending.ContainerId, + pending.Placement); + Populate(); + } + private void OnPendingBackpackPlacementCancelled(PendingBackpackPlacement pending) + { + if (_pendingListPlacement is not { } projection + || projection.Token != pending.Token) + return; + _pendingListPlacement = null; + Populate(); + } + private void OnPendingBackpackPlacementResolved(PendingBackpackPlacement pending) + { + if (_pendingListPlacement is not { } projection + || projection.Token != pending.Token) + return; + _pendingListPlacement = null; Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnMoveRequestFailed(MoveRequestFailure failure) { - if (_pendingListPlacement?.ItemId != failure.ItemId) + if (_itemInteraction is not null + || _pendingListPlacement?.ItemId != failure.ItemId) return; _pendingListPlacement = null; Populate(); @@ -531,9 +552,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo return ItemDragAcceptance.None; if (payload.ObjId == 0) return ItemDragAcceptance.Reject; - if (payload.SourceKind == ItemDragSource.Ground - && _pendingListPlacement is not null) - return ItemDragAcceptance.Reject; if (targetList == _contentsGrid) return ItemDragAcceptance.Accept; if (targetList == _containerList || targetList == _topContainer) @@ -561,6 +579,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint item = payload.ObjId; if (item == 0) return; + // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every + // release while m_pendingItem exists, before merge, split, or ordinary + // placement. ItemList_DragOver has no equivalent gate, so hover may + // still show the authored accept cursor immediately before this no-op. + if (targetList == _contentsGrid + && _pendingListPlacement is { } localPending + && localPending.ContainerId == EffectiveOpen()) + { + _itemInteraction?.ReportPendingBackpackPlacementConflict(); + return; + } + if (_itemInteraction is not null + && !_itemInteraction.EnsureInventoryRequestReady()) + { + return; + } + // UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first // when a physical item is released on an occupied inventory cell. A legal // merge consumes the drop; an illegal merge falls through to insert/move. @@ -597,8 +632,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { // UIAttemptSplitToContainer leaves the source stack where it is. ACE will // publish the reduced source plus a newly-guided destination stack. - _sendStackableSplitToContainer?.Invoke( - item, container, (uint)placement, splitSize); + DispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + item, + () => + { + if (_sendStackableSplitToContainer is null) + return false; + _sendStackableSplitToContainer( + item, + container, + (uint)placement, + splitSize); + return true; + }); return; } } @@ -610,13 +657,52 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680. if (payload.SourceKind == ItemDragSource.Ground) { - if (_pendingListPlacement is not null) + if (_itemInteraction is not null) + { + if (!_itemInteraction.TryDispatchPendingBackpackPlacement( + item, + container, + placement, + InventoryRequestKind.Pickup, + () => + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer(item, container, placement); + return true; + })) + { + return; + } return; - _pendingListPlacement = new PendingListPlacement(item, container, placement); - Populate(); + } + else + { + if (_pendingListPlacement is not null) + return; + _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); + Populate(); + } } else { + if (_itemInteraction is not null) + { + DispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => + { + if (_sendPutItemInContainer is null + || !_objects.MoveItemOptimistic(item, container, placement)) + { + return false; + } + _sendPutItemInContainer(item, container, placement); + return true; + }); + return; + } _objects.MoveItemOptimistic(item, container, placement); } _sendPutItemInContainer?.Invoke(item, container, placement); @@ -651,14 +737,30 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (plan is not { } merge) return false; - _sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount); - // ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge; - // it is an attempt notice, not a later server-confirm event. - _notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId); - _selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory); - return true; + return DispatchInventoryRequest( + InventoryRequestKind.Merge, + merge.SourceObjectId, + () => + { + _sendStackableMerge( + merge.SourceObjectId, + merge.TargetObjectId, + merge.Amount); + // ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge; + // it is an attempt notice, not a later server-confirm event. + _notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId); + _selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory); + return true; + }); } + private bool DispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch) + => _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch) + ?? dispatch(); + /// True only when we KNOW the container is full (capacity known + contents indexed). A /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. private bool IsContainerFull(uint container) @@ -691,7 +793,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint p = _playerGuid(); _openContainer = guid; if (guid != p) - _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + { + if (_itemInteraction is not null) + _itemInteraction.ActivateItem(guid); + else + _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + } Populate(); // repaint the grid for the new open container } @@ -814,6 +921,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { _itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved -= OnPendingBackpackPlacementResolved; } } } diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 4b7969e2..84a9a4f2 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -519,6 +519,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint player = _playerGuid?.Invoke() ?? 0u; if (player == 0 || _repo.Get(payload.ObjId) is null) return; + if (_itemInteraction is not null) + { + Func dispatch = () => + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer(payload.ObjId, player, 0); + return true; + }; + if (_itemInteraction.IsOwnedByPlayer(payload.ObjId)) + _itemInteraction.TryDispatchPendingBackpackPlacement( + payload.ObjId, + player, + 0, + InventoryRequestKind.PutInContainer, + dispatch); + else + _itemInteraction.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + payload.ObjId, + dispatch); + return; + } _sendPutItemInContainer?.Invoke(payload.ObjId, player, 0); } diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 86526f9e..0093e68b 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement( /// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 snapshots and /// publishes both complete placements after applying the new state. /// +public enum ClientObjectMoveOrigin +{ + LocalProjection, + ServerRollbackProjection, + AuthoritativeResponse, +} + public readonly record struct ClientObjectMove( uint ItemId, ClientObject? Item, ClientObjectPlacement Previous, - ClientObjectPlacement Current); + ClientObjectPlacement Current, + ClientObjectMoveOrigin Origin); public enum ClientObjectRemovalReason { @@ -119,7 +127,10 @@ public sealed class ClientObjectTable /// /// Fires after a complete placement notice (moved between packs, - /// equipped, unequipped, dropped on ground). + /// equipped, unequipped, dropped on ground). + /// distinguishes an immediate local projection from ACE's authoritative + /// response; transaction owners must complete only on the latter. + /// /// is null when retail publishes the notice before CreateObject resolves /// the GUID; the old/new placement remains authoritative notice data. /// @@ -168,6 +179,13 @@ public sealed class ClientObjectTable /// Fires when an object's properties are updated (typically after Appraise). public event Action? ObjectUpdated; + /// + /// Fires only for an authoritative SetStackSize response. Inventory request + /// owners use this narrower seam instead of treating an unrelated appraisal + /// or property update as the response to a merge, split, give, or drop. + /// + public event Action? StackSizeUpdated; + /// Fires after all session object and pending-move state is flushed. public event Action? Cleared; @@ -242,7 +260,8 @@ public sealed class ClientObjectTable newSlot, item.WielderId, newEquipLocation), - containerTypeHint); + containerTypeHint, + ClientObjectMoveOrigin.LocalProjection); } /// @@ -270,13 +289,15 @@ public sealed class ClientObjectTable newSlot, newWielderId, newEquipLocation), - containerTypeHint); + containerTypeHint, + ClientObjectMoveOrigin.AuthoritativeResponse); } private bool ApplyPlacement( ClientObject item, ClientObjectPlacement current, - uint? containerTypeHint = null) + uint? containerTypeHint, + ClientObjectMoveOrigin origin) { ClientObjectPlacement previous = ClientObjectPlacement.From(item); List? changedContainers = RemoveFromOtherContainerIndexes( @@ -289,18 +310,19 @@ public sealed class ClientObjectTable if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; Reindex(item, previous.ContainerId); - PublishPlacementChange(item, previous); + PublishPlacementChange(item, previous, origin); PublishContainerContentsChanges(changedContainers); return true; } private void PublishPlacementChange( ClientObject item, - ClientObjectPlacement previous) + ClientObjectPlacement previous, + ClientObjectMoveOrigin origin) { ClientObjectPlacement current = ClientObjectPlacement.From(item); UpdateEquipmentIndex(item.ObjectId, previous, current); - ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current)); + ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin)); } /// @@ -337,7 +359,8 @@ public sealed class ClientObjectTable newContainerId, newSlot, newWielderId, - newEquipLocation))); + newEquipLocation), + ClientObjectMoveOrigin.AuthoritativeResponse)); return false; } @@ -364,14 +387,17 @@ public sealed class ClientObjectTable itemId, Item: null, Previous: default, - Current: placement)); + Current: placement, + ClientObjectMoveOrigin.AuthoritativeResponse)); WieldConfirmed?.Invoke(itemId); return false; } if (!ApplyPlacement( item, - placement)) + placement, + containerTypeHint: null, + ClientObjectMoveOrigin.AuthoritativeResponse)) { return false; } @@ -434,7 +460,7 @@ public sealed class ClientObjectTable } else item.ContainerSlot = newSlot; - PublishPlacementChange(item, previous); + PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection); PublishContainerContentsChanges(changedContainers); return true; } @@ -485,12 +511,12 @@ public sealed class ClientObjectTable if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; _pendingMoves.Remove(itemId); ClientObjectPlacement placement = pre.placement; - if (!ApplyServerMove( - itemId, - placement.ContainerId, - placement.WielderId, - placement.ContainerSlot, - placement.EquipLocation)) + if (!_objects.TryGetValue(itemId, out ClientObject? item) + || !ApplyPlacement( + item, + placement, + containerTypeHint: null, + ClientObjectMoveOrigin.ServerRollbackProjection)) return false; MoveRolledBack?.Invoke(_objects[itemId]); return true; @@ -654,6 +680,7 @@ public sealed class ClientObjectTable item.StackSize = stackSize; item.Value = value; ObjectUpdated?.Invoke(item); + StackSizeUpdated?.Invoke(item); return true; } diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs index ac943c80..3bd27b9c 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs @@ -167,6 +167,14 @@ public sealed class MoveToManager /// public Action? MoveToComplete { get; set; } + /// + /// CLIENT ADDITION — cancellation companion to . + /// Fires only when actually tears down an + /// active move. It never changes retail movement behavior; App owners use + /// it to withdraw presentation state which was waiting for natural arrival. + /// + public Action? MoveToCancelled { get; set; } + /// Retail Timer::cur_time — injectable clock (tests drive /// this explicitly; production binds to the real wall/game clock). /// Defaults to a monotonically-increasing stub if not overridden via the @@ -1455,18 +1463,18 @@ public sealed class MoveToManager /// , , then /// StopCompletely. The argument is NEVER READ /// in retail's body (every call site's error code is dropped in this - /// build, decomp §7c) — kept for parity/logging/tests only; NO behavior - /// depends on its value. + /// build, decomp §7c). The retail body remains independent of it; only the + /// documented client-addition observes it + /// after teardown so App presentation can classify the cancellation. /// public void CancelMoveTo(WeenieError error) { - _ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging. - if (MovementTypeState == MovementType.Invalid) return; _pendingActions.Clear(); CleanUp(); if (HasPhysicsObj) _stopCompletely(); + MoveToCancelled?.Invoke(error); } /// diff --git a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs index e561cb5e..e1c10600 100644 --- a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs +++ b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs @@ -53,4 +53,43 @@ public sealed class OutboundInteractionQueueTests Assert.False(invoked); } + + [Fact] + public void Drain_ReentrantClearStopsTheCapturedBatch() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + queue.Enqueue(() => + { + actual.Add("first"); + queue.Clear(); + }); + queue.Enqueue(() => actual.Add("stale")); + + queue.Drain(); + + Assert.Equal(new[] { "first" }, actual); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Drain_ReentrantClearAndEnqueueDefersTheNewEpoch() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + queue.Enqueue(() => + { + actual.Add("first"); + queue.Clear(); + queue.Enqueue(() => actual.Add("new-session")); + }); + queue.Enqueue(() => actual.Add("stale")); + + queue.Drain(); + Assert.Equal(new[] { "first" }, actual); + Assert.Equal(1, queue.Count); + + queue.Drain(); + Assert.Equal(new[] { "first", "new-session" }, actual); + } } diff --git a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs new file mode 100644 index 00000000..8cc44107 --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs @@ -0,0 +1,106 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Interaction; + +public sealed class PlayerInteractionMovementSinkTests +{ + private const uint Player = 0x5000_0001u; + private const uint Target = 0x7000_0001u; + private const uint Cell = 0x0101_0001u; + + [Fact] + public void MissingPlayerDoesNotArmTheIntent() + { + var sink = new PlayerInteractionMovementSink(() => null); + bool armed = false; + + Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true)); + Assert.False(armed); + } + + [Theory] + [InlineData(true, MovementType.TurnToObject)] + [InlineData(false, MovementType.MoveToObject)] + public void ProductionSinkCancelsThenArmsAndInstallsExactMovement( + bool closeRange, + MovementType expectedType) + { + var controller = new PlayerMovementController(new PhysicsEngine()); + controller.SetPosition(Vector3.Zero, Cell); + bool nonAutonomousAtTargetInstall = false; + int cancellations = 0; + double targetQuantum = 0d; + var moveTo = new MoveToManager( + controller.Motion, + stopCompletely: () => { }, + getPosition: () => controller.CellPosition, + getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), + setHeading: (heading, _) => controller.Yaw = MoveToMath.YawFromHeading(heading), + getOwnRadius: () => 0.4f, + getOwnHeight: () => 1.8f, + contact: () => true, + isInterpolating: () => false, + getVelocity: () => Vector3.Zero, + getSelfId: () => Player, + setTarget: (_, topLevelId, radius, quantum) => + { + Assert.Equal(Target, topLevelId); + Assert.Equal(0.5f, radius); + targetQuantum = quantum; + nonAutonomousAtTargetInstall = + controller.Motion.PhysicsObj?.LastMoveWasAutonomous == false; + }, + clearTarget: () => { }, + getTargetQuantum: () => targetQuantum, + setTargetQuantum: quantum => targetQuantum = quantum); + moveTo.MoveToCancelled = _ => cancellations++; + controller.MoveTo = moveTo; + + // Prove the sink's explicit cancel occurs before its arm callback. + moveTo.MoveToPosition( + new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity), + new MovementParameters { UseSpheres = false }); + bool armedAfterCancellation = false; + var sink = new PlayerInteractionMovementSink(() => controller); + + Assert.True(sink.BeginApproach( + Approach(closeRange), + () => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo())); + + Assert.True(armedAfterCancellation); + Assert.True(nonAutonomousAtTargetInstall); + Assert.Equal(expectedType, moveTo.MovementTypeState); + Assert.Equal(Target, moveTo.SoughtObjectId); + Assert.Equal(Target, moveTo.TopLevelObjectId); + Assert.Equal(closeRange ? 0f : 0.75f, moveTo.SoughtObjectRadius); + Assert.Equal(closeRange ? 0f : 2.25f, moveTo.SoughtObjectHeight); + Assert.Equal(0.6f, moveTo.Params.DistanceToObject); + Assert.Equal(!closeRange, moveTo.Params.CanCharge); + } + + private static InteractionApproach Approach(bool closeRange) + { + var entity = new WorldEntity + { + Id = 101u, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = new Vector3(5f, 0f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + return new InteractionApproach( + new WorldInteractionTarget(Target, entity.Id, entity), + new PlayerInteractionPose(Cell, Vector3.Zero), + UseRadius: 0.6f, + IsCloseRange: closeRange, + CanCharge: !closeRange, + TargetRadius: 0.75f, + TargetHeight: 2.25f); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index c9fa956f..7c64a8e8 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -26,6 +26,8 @@ public sealed class SelectionInteractionControllerTests public bool Hostile { get; set; } public bool Useable { get; set; } = true; public bool Pickupable { get; set; } = true; + public bool CaptureIdentity { get; set; } = true; + public uint LocalEntityId { get; set; } = 101u; public ClosestCombatTarget? Closest { get; set; } public InteractionApproach? Approach { get; set; } public List Events { get; } = new(); @@ -41,6 +43,11 @@ public sealed class SelectionInteractionControllerTests => PickAtCursor(includeSelf); public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse"); + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + localEntityId = LocalEntityId; + return CaptureIdentity; + } public bool IsCurrent(uint serverGuid, uint localEntityId) => Current; public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; public bool IsCreature(uint serverGuid) => Creature; @@ -100,9 +107,15 @@ public sealed class SelectionInteractionControllerTests private sealed class Movement : IPlayerInteractionMovementSink { public List Approaches { get; } = new(); - public bool BeginApproach(InteractionApproach approach) + public bool Starts { get; set; } = true; + public Action? AfterArm { get; set; } + public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) { Approaches.Add(approach); + if (!Starts) + return false; + armAfterCancel?.Invoke(); + AfterArm?.Invoke(); return true; } } @@ -116,6 +129,8 @@ public sealed class SelectionInteractionControllerTests public readonly ClientObjectTable Objects = new(); public readonly List Toasts = new(); public readonly List Examines = new(); + public readonly List PendingPlacements = new(); + public readonly List CancelledPlacements = new(); public readonly ItemInteractionController Items; public readonly SelectionInteractionController Controller; @@ -127,6 +142,12 @@ public sealed class SelectionInteractionControllerTests ObjectId = Player, Type = ItemType.Creature, }); + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Target, + Name = "Target", + Type = ItemType.Misc, + }); Items = new ItemInteractionController( Objects, () => Player, @@ -148,6 +169,8 @@ public sealed class SelectionInteractionControllerTests Transport, Movement, Toasts.Add); + Items.PendingBackpackPlacementRequested += PendingPlacements.Add; + Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add; } public void SetApproach(bool closeRange, uint localEntityId = 101u) @@ -270,12 +293,14 @@ public sealed class SelectionInteractionControllerTests h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.SelectionPickUp); h.Controller.SendUse(Target); + h.Items.InteractionState.EnterExamine(); h.Controller.ResetSession(); h.Controller.DrainOutbound(); h.Controller.OnNaturalMoveToComplete(); Assert.Null(h.Selection.SelectedObjectId); + Assert.False(h.Items.IsAnyTargetModeActive); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Transport.Uses); } @@ -305,6 +330,216 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.Transport.Uses); } + [Fact] + public void SynchronousNaturalCompletionObservesTheArmedCloseAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete; + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void SynchronousResetCannotResurrectTheCloseAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.AfterArm = h.Controller.ResetSession; + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void CancelledClosePickupWithdrawsPresentationAndNeverFiresLater() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + Assert.Single(h.PendingPlacements); + Assert.Empty(h.Transport.Pickups); + + h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void ErrorCompletionClearsCloseActionBeforeALaterSuccess() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Controller.SendUse(Target); + + h.Controller.OnMoveToCancelled(WeenieError.NoObject); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void FailedClosePickupStartWithdrawsPresentation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.Starts = false; + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + + Assert.Single(h.PendingPlacements); + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InvalidKeyboardPickupNeverPublishesAWaitingSlot(bool creature) + { + var h = new Harness(); + h.Query.Creature = creature; + h.Query.Pickupable = false; + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.DrainOutbound(); + + Assert.Empty(h.PendingPlacements); + Assert.Empty(h.Transport.Pickups); + } + + [Theory] + [InlineData(InputAction.SelectDblLeft)] + [InlineData(InputAction.UseSelected)] + [InlineData(InputAction.SelectionPickUp)] + public void QueuedWorldActionCannotCrossAnIncarnationBoundary(InputAction action) + { + var h = new Harness(); + h.Query.Picked = Target; + h.SetApproach(closeRange: false); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(action); + h.Query.Current = false; + h.Controller.DrainOutbound(); + + Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.PendingPlacements); + } + + [Fact] + public void DragReleasePulsesTheDropTargetWithoutChangingSelection() + { + var h = new Harness(); + h.Query.Picked = Target; + const uint item = 0x7000_0002u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Dragged", + Type = ItemType.Misc, + }); + var payload = new ItemDragPayload( + item, + ItemDragSource.Inventory, + SourceSlot: 0, + new UiItemSlot()); + + h.Controller.PlaceDraggedItem(payload, 10f, 20f); + + Assert.Contains("pulse", h.Query.Events); + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void RejectedTargetModeClickConsumesTheFollowingDoubleClick() + { + var h = new Harness(); + h.Query.Picked = Target; + h.Objects.Remove(Target); + h.Items.InteractionState.EnterUse(); + + h.Controller.HandleInputAction(InputAction.SelectLeft); + h.Controller.HandleInputAction(InputAction.SelectDblLeft); + h.Controller.DrainOutbound(); + + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void TransportLossAtClosePickupCompletionWithdrawsPresentation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + + h.Transport.IsInWorld = false; + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void RepeatedPickupKeepsTheOriginalReservationAndDeferredAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.DrainOutbound(); + + Assert.Single(h.PendingPlacements); + Assert.Empty(h.CancelledPlacements); + + h.Controller.OnNaturalMoveToComplete(); + Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); + } + + [Fact] + public void WithdrawnPickupReservationInvalidatesDeferredWireAction() + { + const uint replacement = 0x7000_0002u; + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = replacement, + Name = "Replacement loot", + Type = ItemType.Misc, + }); + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + Assert.Empty(h.Transport.Pickups); + PendingBackpackPlacement original = Assert.Single(h.PendingPlacements); + h.Items.CancelPendingBackpackPlacement(Target, original.Token); + Assert.True(h.Items.TryBeginPendingBackpackPlacement( + replacement, + Player, + placement: 0, + out _)); + + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Pickups); + Assert.True(h.Items.TryGetPendingBackpackPlacement(replacement, out _)); + Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _)); + } + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => new( guid, diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index 0bb1ed9f..a1cc6f13 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -32,6 +32,9 @@ public sealed class WorldSelectionQueryTests public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh; } + private sealed record Animation(WorldEntity Entity, uint CurrentMotion) + : ILiveEntityAnimationRuntime; + private sealed class Harness { public readonly ClientObjectTable Objects = new(); @@ -131,6 +134,41 @@ public sealed class WorldSelectionQueryTests Assert.Null(h.Query.PickAtCursor(includeSelf: false)); } + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition) + { + var h = new Harness(); + WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc); + h.Publish(target); + Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false)); + + switch (transition) + { + case 0: + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + Target, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + break; + case 1: + Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target)); + break; + default: + Assert.True(h.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Target, 1), + isLocalPlayer: false)); + break; + } + + Assert.Null(h.Query.PickAtCursor(includeSelf: false)); + } + [Fact] public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters() { @@ -150,6 +188,43 @@ public sealed class WorldSelectionQueryTests new Vector3(1f, 0f, 0f), ItemType.Misc, SelectedObjectHealthPolicy.BfAttackable); + h.Add( + 0x7000_0013u, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer); + WorldEntity pet = h.Add( + 0x7000_0014u, + new Vector3(4f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player; + WorldEntity dead = h.Add( + 0x7000_0015u, + new Vector3(5f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.Runtime.SetAnimationRuntime( + dead.ServerGuid, + new Animation(dead, MotionCommand.Dead)); + WorldEntity hidden = h.Add( + 0x7000_0016u, + new Vector3(6f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + hidden.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + WorldEntity pending = h.Add( + 0x7000_0017u, + new Vector3(7f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid)); ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster(); diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index f2d3fd0d..36b8275e 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1424,14 +1424,445 @@ public sealed class ItemInteractionControllerTests { var h = new Harness(); const uint item = 0x70000A0Bu; - var pending = new List<(uint Item, uint Container, int Placement)>(); - h.Controller.PendingBackpackPlacementRequested += - (objectId, container, placement) => pending.Add((objectId, container, placement)); + var pending = new List(); + h.Controller.PendingBackpackPlacementRequested += pending.Add; Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); - Assert.Equal(new[] { (item, Player, 0) }, pending); + Assert.Collection( + pending, + placement => + { + Assert.NotEqual(0u, placement.Token); + Assert.Equal(item, placement.ItemId); + Assert.Equal(Player, placement.ContainerId); + Assert.Equal(0, placement.Placement); + }); Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(InventoryRequestKind.Pickup, request.Kind); + Assert.False(request.Dispatched); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + item, + static () => true, + pending[0].Token)); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out request)); + Assert.True(request.Dispatched); + } + + [Fact] + public void PendingRequestSerializesEveryLaterItemWithoutSecondWire() + { + var h = new Harness(); + const uint item = 0x70000A0Du; + const uint laterItem = 0x70000A0Eu; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Loot", + Type = ItemType.Misc, + }); + var requested = new List(); + var cancelled = new List(); + h.Controller.PendingBackpackPlacementRequested += requested.Add; + h.Controller.PendingBackpackPlacementCancelled += cancelled.Add; + + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + PendingBackpackPlacement first = Assert.Single(requested); + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(laterItem)); + + Assert.Single(requested); + Assert.Empty(cancelled); + Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); + Assert.Equal( + new[] + { + "Already attempting to place Loot here", + "Already attempting to place Loot here", + }, + h.SystemMessages); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(item, out var current)); + Assert.Equal(first, current); + + h.Objects.RejectMove(item, weenieError: 0x29u); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.Empty(cancelled); + } + + [Fact] + public void BusyInventoryTransactionPreventsPickupReservationAndWireRequest() + { + var h = new Harness(); + const uint item = 0x70000A0Fu; + h.Controller.IncrementBusyCount(); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + + Assert.Empty(h.BackpackPlacements); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsContainedUseRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A10u; + const uint contained = 0x50000A11u; + h.AddContained(contained, item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + h.Now += 200; + h.Controller.ActivateItem(contained); + + Assert.Empty(h.Uses); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(pickup, out _)); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsPaperdollWieldRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A12u; + const uint helm = 0x50000A13u; + h.AddContained(helm, item => item.ValidLocations = EquipMask.HeadWear); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.False(h.Controller.WieldFromPaperdoll(helm, EquipMask.HeadWear)); + + Assert.Empty(h.Wields); + Assert.Equal(EquipMask.None, h.Objects.Get(helm)!.CurrentlyEquippedLocation); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsConfirmedUseRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A14u; + const uint item = 0x50000A15u; + h.AddContained(item, contained => contained.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.False(h.Controller.ExecuteConfirmedUse(item)); + + Assert.Empty(h.Uses); + Assert.Equal(0, h.Controller.BusyCount); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void GlobalInventoryRequestRejectsPickupUntilMatchingMoveResponse() + { + var h = new Harness(); + const uint moving = 0x50000A16u; + const uint unrelated = 0x50000A17u; + const uint pickup = 0x70000A18u; + h.AddContained(moving); + h.AddContained(unrelated); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + moving, + static () => true)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + + Assert.Empty(h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(moving, pending.ItemId); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + + h.Objects.ApplyConfirmedServerMove(unrelated, Pack, 0u, 0); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out _)); + + h.Objects.ApplyConfirmedServerMove(moving, Pack, 0u, 0); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.Equal(new[] { (pickup, Player, 0) }, h.BackpackPlacements); + } + + [Fact] + public void SplitRequestReleasesOnlyOnSourceStackResponse() + { + var h = new Harness(); + const uint source = 0x50000A19u; + const uint unrelated = 0x50000A1Au; + h.AddContained(source, item => item.StackSize = 10); + h.AddContained(unrelated, item => item.StackSize = 10); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + source, + static () => true)); + h.Objects.UpdateStackSize(unrelated, 9, 0); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out _)); + + h.Objects.UpdateStackSize(source, 9, 0); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + + [Fact] + public void MatchingInventoryFailureReleasesGlobalRequest() + { + var h = new Harness(); + const uint source = 0x50000A1Bu; + h.AddContained(source); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + source, + static () => true)); + + h.Objects.RejectMove(source, weenieError: 0x29u); + + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + + [Fact] + public void ProvisionalInventoryOwnerRejectsReentrantSecondDispatch() + { + var h = new Harness(); + const uint first = 0x50000A1Cu; + const uint second = 0x50000A1Du; + h.AddContained(first); + h.AddContained(second); + bool secondDispatched = true; + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + first, + () => + { + secondDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true); + return true; + })); + + Assert.False(secondDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(first, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void SynchronousMatchingResponseDoesNotResurrectProvisionalOwner() + { + var h = new Harness(); + const uint item = 0x50000A1Eu; + h.AddContained(item); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0))); + + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + + [Fact] + public void OptimisticMoveKeepsGlobalOwnerUntilAuthoritativeResponse() + { + var h = new Harness(); + const uint item = 0x50000A20u; + const uint second = 0x50000A21u; + h.AddContained(item); + h.AddContained(second); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => h.Objects.MoveItemOptimistic(item, Player, 0))); + + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(item, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.False(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true)); + + Assert.True(h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0)); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + + [Fact] + public void AuthoritativeResponseReleasesGlobalOwnerBeforePublishingPlacementResolution() + { + var h = new Harness(); + const uint first = 0x70000A22u; + const uint second = 0x50000A23u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = first, + Name = "Loot", + Type = ItemType.Misc, + }); + h.AddContained(second); + bool secondDispatched = false; + h.Controller.PendingBackpackPlacementResolved += _ => + secondDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true); + + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + first, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true)); + + Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0)); + + Assert.True(secondDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(second, pending.ItemId); + Assert.True(pending.Dispatched); + } + + [Fact] + public void FailedMoveReleasesOldOwnerOnceAndPreservesReentrantSameItemRequest() + { + var h = new Harness(); + const uint item = 0x50000A24u; + h.AddContained(item); + bool replacementDispatched = false; + h.Controller.PendingBackpackPlacementResolved += _ => + replacementDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + static () => true); + + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + item, + Player, + 0, + InventoryRequestKind.PutInContainer, + () => h.Objects.MoveItemOptimistic(item, Player, 0))); + + Assert.True(h.Objects.RejectMove(item, weenieError: 0x29u)); + + Assert.True(replacementDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(item, pending.ItemId); + Assert.True(pending.Dispatched); + } + + [Fact] + public void ResponseClearsGlobalAndLocalStateBeforeReadinessNotification() + { + var h = new Harness(); + const uint first = 0x70000A25u; + const uint second = 0x70000A26u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = first, + Name = "First loot", + Type = ItemType.Misc, + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = second, + Name = "Second loot", + Type = ItemType.Misc, + }); + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + first, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true)); + + var eventOrder = new List(); + h.Controller.PendingBackpackPlacementResolved += placement => + { + if (placement.ItemId == first) + eventOrder.Add("resolved-first"); + }; + h.Controller.PendingBackpackPlacementRequested += placement => + { + if (placement.ItemId == second) + eventOrder.Add("requested-second"); + }; + bool attempted = false; + bool secondDispatched = false; + h.Controller.StateChanged += () => + { + if (attempted || !h.Controller.CanMakeInventoryRequest) + return; + attempted = true; + eventOrder.Add("ready"); + secondDispatched = h.Controller.TryDispatchPendingBackpackPlacement( + second, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true); + }; + + Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0)); + + Assert.True(attempted); + Assert.True(secondDispatched); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(second, out _)); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(second, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.Equal( + new[] { "resolved-first", "ready", "requested-second" }, + eventOrder); + } + + [Fact] + public void ReentrantPendingPublicationCancellationPreventsWireDispatch() + { + var h = new Harness(); + const uint item = 0x70000A1Fu; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Loot", + Type = ItemType.Misc, + }); + bool wireDispatched = false; + h.Controller.PendingBackpackPlacementRequested += pending => + h.Controller.CancelPendingBackpackPlacement(pending.ItemId, pending.Token); + + Assert.False(h.Controller.TryDispatchPendingBackpackPlacement( + item, + Player, + 0, + InventoryRequestKind.Pickup, + () => + { + wireDispatched = true; + return true; + })); + + Assert.False(wireDispatched); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index 993a084c..ec06a3db 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -263,4 +263,81 @@ public sealed class ExternalContainerControllerTests Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); Assert.Empty(h.Puts); } + + [Fact] + public void PendingPickupRejectsOwnedDropIntoChest() + { + using var h = new Harness(); + h.Open(); + h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc }); + h.Objects.MoveItem(Item, Player, 0); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A01u)); + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + + Assert.Empty(h.Puts); + Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); + Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A01u, out _)); + } + + [Fact] + public void PendingPickupRejectsPartialStackSplitIntoChest() + { + using var h = new Harness(); + h.Open(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = Item, + StackSize = 5, + StackSizeMax = 100, + Type = ItemType.Misc, + }); + h.Objects.MoveItem(Item, Player, 0); + h.Selection.Select(Item, SelectionChangeSource.Inventory); + h.Split.Reset(5u, 2u); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A02u)); + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + + Assert.Empty(h.Splits); + Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); + Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A02u, out _)); + } + + [Fact] + public void ExternalPutOwnsGlobalGateUntilMatchingServerMove() + { + using var h = new Harness(); + const uint pickup = 0x70000A03u; + h.Open(); + h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc }); + h.Objects.MoveItem(Item, Player, 0); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + Assert.True(h.Interaction.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(Item, pending.ItemId); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup)); + Assert.Empty(h.Pickups); + + h.Objects.ApplyConfirmedServerMove(Item, Chest, 0u, 0); + Assert.False(h.Interaction.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup)); + Assert.Equal(new uint[] { pickup }, h.Pickups); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 94734057..5f717fc4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -706,6 +706,467 @@ public class InventoryControllerTests Assert.Equal(0xBu, grid.GetItem(2)!.ItemId); } + [Fact] + public void DirectLootWhilePendingKeepsTheOriginalProjectionAndRequest() + { + const uint chest = 0x70000021u; + const uint firstLoot = 0x70000022u; + const uint secondLoot = 0x70000023u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, firstLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, secondLoot, chest, slot: 1, type: ItemType.Misc); + long now = 1_000; + var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => now, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId, pending.Token)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId, pending.Token)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.ActivateItem(firstLoot)); + now += 200; + Assert.False(interaction.ActivateItem(secondLoot)); + + Assert.Collection( + eventOrder, + first => Assert.Equal(("request", firstLoot), (first.Kind, first.Item))); + Assert.Equal(firstLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == secondLoot); + } + + [Fact] + public void GroundDragThenDirectPickupKeepsTheFirstPendingProjection() + { + const uint chest = 0x70000031u; + const uint draggedLoot = 0x70000032u; + const uint directLoot = 0x70000033u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, draggedLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, directLoot, chest, slot: 1, type: ItemType.Misc); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var eventOrder = new List<(string Kind, uint Item)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: (item, container, placement) => + puts.Add((item, container, placement))); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + var source = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + source.SetItem(draggedLoot, 0u); + + inventory.HandleDropRelease( + grid, + grid.GetItem(4)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)); + Assert.True(interaction.PlaceWorldItemInBackpack(directLoot)); + + Assert.Equal(new[] { ("request", draggedLoot) }, eventOrder); + Assert.Equal(new[] { (draggedLoot, Player, 0) }, puts); + Assert.Equal(draggedLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == directLoot); + } + + [Fact] + public void DirectPickupThenGroundDragAcceptsHoverButRejectsTheSecondRequest() + { + const uint chest = 0x70000041u; + const uint directLoot = 0x70000042u; + const uint draggedLoot = 0x70000043u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, directLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, draggedLoot, chest, slot: 1, type: ItemType.Misc); + var eventOrder = new List<(string Kind, uint Item)>(); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: (item, container, placement) => + puts.Add((item, container, placement)), + systemMessage: messages.Add); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + var source = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + source.SetItem(draggedLoot, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(directLoot)); + Assert.Equal( + ItemDragAcceptance.Accept, + inventory.OnDragOver( + grid, + grid.GetItem(5)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source))); + inventory.HandleDropRelease( + grid, + grid.GetItem(5)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)); + + Assert.Equal(new[] { ("request", directLoot) }, eventOrder); + Assert.Equal(new[] { (directLoot, Player, 0) }, puts); + Assert.Equal(directLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.Equal( + new[] { "Already attempting to place that item here" }, + messages); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == draggedLoot); + } + + [Fact] + public void PendingContentsProjectionUsesGenericGlobalMessageOnBagColumnDrop() + { + const uint chest = 0x70000044u; + const uint pendingLoot = 0x70000045u; + const uint ownedItem = 0x50000046u; + const uint bag = 0x50000047u; + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, 0, type: ItemType.Misc); + SeedContained(objects, ownedItem, Player, 0, type: ItemType.Misc); + SeedBag(objects, bag, 1); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }, + systemMessage: messages.Add); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease( + containers, + containers.GetItem(0)!, + Payload(ownedItem)); + + Assert.Empty(puts); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + messages); + } + + [Fact] + public void PendingPickupRejectsCompatibleGroundMergeBeforeWireDispatch() + { + const uint chest = 0x70000071u; + const uint pendingLoot = 0x70000072u; + const uint sourceStack = 0x70000073u; + const uint targetStack = 0x70000074u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + objects.AddOrUpdate(new ClientObject + { + ObjectId = sourceStack, + WeenieClassId = 0x1234u, + StackSize = 10, + StackSizeMax = 100, + }); + objects.MoveItem(sourceStack, chest, 1); + objects.AddOrUpdate(new ClientObject + { + ObjectId = targetStack, + WeenieClassId = 0x1234u, + StackSize = 50, + StackSizeMax = 100, + }); + objects.MoveItem(targetStack, Player, 0); + var merges = new List<(uint Source, uint Target, uint Amount)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendStackableMerge: (source, target, amount) => + merges.Add((source, target, amount)), + itemInteraction: interaction); + var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + sourceCell.SetItem(sourceStack, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + UiItemSlot targetCell = Enumerable.Range(0, grid.GetNumUIItems()) + .Select(i => grid.GetItem(i)!) + .Single(cell => cell.ItemId == targetStack); + inventory.HandleDropRelease( + grid, + targetCell, + new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell)); + + Assert.Empty(merges); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingPickupRejectsPartialGroundSplitBeforeWireDispatch() + { + const uint chest = 0x70000081u; + const uint pendingLoot = 0x70000082u; + const uint sourceStack = 0x70000083u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + objects.AddOrUpdate(new ClientObject + { + ObjectId = sourceStack, + StackSize = 10, + StackSizeMax = 100, + }); + objects.MoveItem(sourceStack, chest, 1); + var selection = new SelectionState(); + selection.Select(sourceStack, SelectionChangeSource.Inventory); + var splitQuantity = new StackSplitQuantityState(); + splitQuantity.Reset(10u); + splitQuantity.SetValue(1u); + var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: selection, + datFont: null, + sendStackableSplitToContainer: (item, container, placement, amount) => + splits.Add((item, container, placement, amount)), + itemInteraction: interaction, + stackSplitQuantity: splitQuantity); + var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + sourceCell.SetItem(sourceStack, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease( + grid, + grid.GetItem(5)!, + new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell)); + + Assert.Empty(splits); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingPickupRejectsOwnedInventoryMoveBeforeOptimisticMutation() + { + const uint chest = 0x70000091u; + const uint pendingLoot = 0x70000092u; + const uint ownedItem = 0x70000093u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, ownedItem, Player, slot: 0, type: ItemType.Misc); + var puts = new List<(uint Item, uint Container, int Placement)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease(grid, grid.GetItem(5)!, Payload(ownedItem)); + + Assert.Empty(puts); + Assert.Equal(Player, objects.Get(ownedItem)!.ContainerId); + Assert.Equal(0, objects.Get(ownedItem)!.ContainerSlot); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingLootRemovalImmediatelyWithdrawsTheProjection() + { + const uint chest = 0x70000051u; + const uint loot = 0x70000052u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(loot)); + Assert.Equal(loot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + + Assert.True(objects.Remove(loot)); + + Assert.Equal(0u, grid.GetItem(0)!.ItemId); + Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _)); + } + + [Fact] + public void PendingLootGenerationReplacementDoesNotLeaveTheOldProjection() + { + const uint chest = 0x70000061u; + const uint loot = 0x70000062u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(loot)); + Assert.Equal(loot, grid.GetItem(0)!.ItemId); + + objects.ReplaceGeneration(WorldReplacement(loot), generation: 2); + + Assert.Equal(0u, grid.GetItem(0)!.ItemId); + Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _)); + } + [Fact] public void LootDrop_ServerFailureRemovesOnlyThePendingProjection() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index f45ddae2..1d1c4775 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -46,7 +46,8 @@ public class PaperdollControllerTests SelectionState? selection = null, IReadOnlyDictionary? emptySlotSprites = null, List<(uint item, uint container, int placement)>? puts = null, - List? systemMessages = null) + List? systemMessages = null, + Action? configureInteraction = null) { var itemInteraction = new ItemInteractionController( objects, @@ -59,6 +60,7 @@ public class PaperdollControllerTests ? null : (item, container, placement) => puts.Add((item, container, placement)), systemMessage: systemMessages is null ? null : systemMessages.Add); + configureInteraction?.Invoke(itemInteraction); return PaperdollController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0x1234u, itemInteraction: itemInteraction, @@ -240,6 +242,38 @@ public class PaperdollControllerTests Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire } + [Fact] + public void HandleDropRelease_pendingPickupRejectsWieldWithoutOptimisticMutation() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const uint pickup = 0x70000D02u; + const uint helm = 0x50000D03u; + SeedPackItem(objects, helm, EquipMask.HeadWear); + var wields = new List<(uint item, uint mask)>(); + var messages = new List(); + ItemInteractionController? interaction = null; + var ctrl = Bind( + layout, + objects, + wields, + systemMessages: messages, + configureInteraction: value => interaction = value); + Assert.True(interaction!.TryBeginPendingBackpackPlacement( + pickup, + Player, + 0, + out _)); + ctrl.HandleDropRelease( + lists[HeadSlot], + lists[HeadSlot].Cell, + new ItemDragPayload(helm, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell)); + + Assert.Empty(wields); + Assert.Equal(EquipMask.None, objects.Get(helm)!.CurrentlyEquippedLocation); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + [Fact] public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index b0521cb4..c21d6e1a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -301,6 +301,97 @@ public class ToolbarControllerTests Assert.Equal(new[] { (item, player, 0) }, puts); } + [Fact] + public void InventoryButton_unownedGroundDropUsesDirectAttemptWithoutDestinationProjection() + { + const uint player = 0x5000u; + const uint item = 0x70005001u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Loot", Type = ItemType.Misc }); + var placements = new List<(uint Item, uint Container, int Placement)>(); + var directPuts = new List<(uint Item, uint Container, int Placement)>(); + using var interaction = new ItemInteractionController( + repo, + playerGuid: () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + placeInBackpack: (i, c, p) => placements.Add((i, c, p))); + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: static (_, _, _, _, _) => 0u, + useItem: static _ => { }, + itemInteraction: interaction, + playerGuid: () => player, + sendPutItemInContainer: (i, c, p) => directPuts.Add((i, c, p))); + var button = (UiButton)layout.FindElement(InventoryButtonId)!; + var payload = new ItemDragPayload(item, ItemDragSource.Ground, 0, new UiItemSlot()); + + Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload))); + Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload))); + + Assert.Empty(placements); + Assert.Equal(new[] { (item, player, 0) }, directPuts); + Assert.False(interaction.TryGetPendingBackpackPlacement(item, out _)); + Assert.True(interaction.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(item, request.ItemId); + } + + [Fact] + public void InventoryButton_ownedDropPublishesDestinationAndGloballyRejectsSecondRequest() + { + const uint player = 0x5000u; + const uint first = 0x70005002u; + const uint second = 0x70005003u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + const uint pack = 0x5004u; + repo.AddOrUpdate(new ClientObject { ObjectId = player, Name = "Player", Type = ItemType.Creature }); + repo.AddOrUpdate(new ClientObject { ObjectId = pack, Name = "Pack", Type = ItemType.Container }); + repo.MoveItem(pack, player, 0); + repo.AddOrUpdate(new ClientObject { ObjectId = first, Name = "First Loot", Type = ItemType.Misc }); + repo.AddOrUpdate(new ClientObject { ObjectId = second, Name = "Second Loot", Type = ItemType.Misc }); + repo.MoveItem(first, pack, 0); + repo.MoveItem(second, pack, 1); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + repo, + playerGuid: () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + systemMessage: messages.Add); + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: static (_, _, _, _, _) => 0u, + useItem: static _ => { }, + itemInteraction: interaction, + playerGuid: () => player, + sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p))); + var button = (UiButton)layout.FindElement(InventoryButtonId)!; + + foreach (uint item in new[] { first, second }) + { + var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 0, new UiItemSlot()); + button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)); + button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)); + } + + Assert.Equal(new[] { (first, player, 0) }, puts); + Assert.True(interaction.TryGetPendingBackpackPlacement(first, out _)); + Assert.True(interaction.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(first, request.ItemId); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + [Fact] public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem() { diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs index 3d5b179a..385b6b1c 100644 --- a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -72,6 +72,38 @@ public sealed class RetailItemConfirmationControllerTests Assert.Equal(0, items.BusyCount); } + [Fact] + public void PositiveConfirmationRechecksGlobalInventoryGateBeforeUse() + { + var objects = BuildObjects(PublicWeenieFlags.VolatileRare); + var uses = new List(); + var messages = new List(); + var items = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: uses.Add, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + placeInBackpack: static (_, _, _) => { }, + systemMessage: messages.Add, + nowMs: () => 1000L); + var root = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout? shown = null; + var factory = new RetailDialogFactory(root, _ => + shown = FixtureLoader.LoadConfirmationDialog()); + using var confirmations = new RetailItemConfirmationController(factory, items); + + Assert.True(items.ActivateItem(Rare)); + Assert.True(items.PlaceWorldItemInBackpack(0x70000001u)); + Assert.IsType(shown!.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.Empty(uses); + Assert.Equal(0, items.BusyCount); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + private static ClientObjectTable BuildObjects(PublicWeenieFlags flags) { var objects = new ClientObjectTable(); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs index 572f1865..83624df8 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs @@ -85,6 +85,27 @@ public sealed class MoveToManagerCompletionSeamTests Assert.Empty(h.MoveToCompleteCalls); } + [Fact] + public void CancelMoveTo_FiresCancellationCompanionOnlyForAnActiveMove() + { + var h = new MoveToManagerHarness(); + var cancellations = new List(); + h.Manager.MoveToCancelled = cancellations.Add; + + h.Manager.CancelMoveTo(WeenieError.ActionCancelled); + Assert.Empty(cancellations); + + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Manager.MoveToPosition( + new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), + new MovementParameters { UseSpheres = false }); + h.Manager.CancelMoveTo(WeenieError.ObjectGone); + h.Manager.CancelMoveTo(WeenieError.ActionCancelled); + + Assert.Equal(new[] { WeenieError.ObjectGone }, cancellations); + Assert.Empty(h.MoveToCompleteCalls); + } + [Fact] public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff() {