fix(interaction): close selection lifecycle review gaps
Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d2bb5af453
commit
5acc3f01cf
23 changed files with 2635 additions and 168 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace AcDream.App.Input;
|
|||
public sealed class OutboundInteractionQueue
|
||||
{
|
||||
private readonly Queue<Action> _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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ namespace AcDream.App.Interaction;
|
|||
|
||||
internal interface IPlayerInteractionMovementSink
|
||||
{
|
||||
bool BeginApproach(InteractionApproach approach);
|
||||
/// <summary>
|
||||
/// Cancels any preceding MoveTo, invokes <paramref name="armAfterCancel"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -20,7 +26,7 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
private readonly Func<PlayerMovementController?> _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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>? _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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
|
|
@ -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<uint> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x00566A20 call family.</summary>
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
|
||||
public bool IsUseable(uint serverGuid)
|
||||
{
|
||||
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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. <paramref name="includeSelf"/> 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);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16 — retail-faithful port of
|
||||
/// <c>SmartBox::GetObjectBoundingBox</c> (decomp <c>0x00452e20</c>)
|
||||
/// using <c>CPhysicsObj::GetSelectionSphere</c> (<c>0x0050ea40</c>)
|
||||
/// → <c>CPartArray::GetSelectionSphere</c> (<c>0x00518b80</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
|
||||
/// it uses the Setup's <c>selection_sphere</c> 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 <c>0x00518ba6–0x00518be3</c>) 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 <c>worldRadius * focalLength / depth</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<uint, uint, int>? PendingBackpackPlacementRequested;
|
||||
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a waiting pickup projection when the approach is rejected or
|
||||
/// cancelled before a request reaches the server.
|
||||
/// </summary>
|
||||
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<PendingBackpackPlacement>? 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;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
|
||||
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
|
||||
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
|
||||
/// </summary>
|
||||
public bool EnsureInventoryRequestReady()
|
||||
{
|
||||
if (CanMakeInventoryRequest)
|
||||
return true;
|
||||
|
||||
if (_pendingInventoryRequest is not null
|
||||
|| _busyCount != 0
|
||||
|| _autoWield.IsBusy)
|
||||
_systemMessage?.Invoke(InventoryRequestBusyMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
|
||||
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
|
||||
/// list which already displays the waiting projection.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches one retail inventory request. A provisional global
|
||||
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
|
||||
/// then becomes dispatched immediately after a successful wire send.
|
||||
/// The callback returns false when it could not dispatch anything.
|
||||
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
|
||||
/// <c>0x0058D590..0x0058D850</c>.
|
||||
/// </summary>
|
||||
public bool TryDispatchInventoryRequest(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
Func<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes retail's destination waiting projection, dispatches the move,
|
||||
/// then records the global request. Failed dispatch withdraws only the
|
||||
/// projection created by this call.
|
||||
/// </summary>
|
||||
public bool TryDispatchPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
int placement,
|
||||
InventoryRequestKind kind,
|
||||
Func<bool> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
|||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<bool> dispatch)
|
||||
=> _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch)
|
||||
?? dispatch();
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool> 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement(
|
|||
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
|
||||
/// publishes both complete placements after applying the new state.
|
||||
/// </summary>
|
||||
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
|
|||
|
||||
/// <summary>
|
||||
/// Fires after a complete placement notice (moved between packs,
|
||||
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
|
||||
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Origin"/>
|
||||
/// distinguishes an immediate local projection from ACE's authoritative
|
||||
/// response; transaction owners must complete only on the latter.
|
||||
/// <see cref="ClientObjectMove.Item"/>
|
||||
/// is null when retail publishes the notice before CreateObject resolves
|
||||
/// the GUID; the old/new placement remains authoritative notice data.
|
||||
/// </summary>
|
||||
|
|
@ -168,6 +179,13 @@ public sealed class ClientObjectTable
|
|||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<ClientObject>? StackSizeUpdated;
|
||||
|
||||
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
|
||||
public event Action? Cleared;
|
||||
|
||||
|
|
@ -242,7 +260,8 @@ public sealed class ClientObjectTable
|
|||
newSlot,
|
||||
item.WielderId,
|
||||
newEquipLocation),
|
||||
containerTypeHint);
|
||||
containerTypeHint,
|
||||
ClientObjectMoveOrigin.LocalProjection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<uint>? 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ public sealed class MoveToManager
|
|||
/// </summary>
|
||||
public Action<WeenieError>? MoveToComplete { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CLIENT ADDITION — cancellation companion to <see cref="MoveToComplete"/>.
|
||||
/// Fires only when <see cref="CancelMoveTo"/> 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.
|
||||
/// </summary>
|
||||
public Action<WeenieError>? MoveToCancelled { get; set; }
|
||||
|
||||
/// <summary>Retail <c>Timer::cur_time</c> — 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
|
|||
/// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then
|
||||
/// StopCompletely. The <paramref name="error"/> 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 <see cref="MoveToCancelled"/> observes it
|
||||
/// after teardown so App presentation can classify the cancellation.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -53,4 +53,43 @@ public sealed class OutboundInteractionQueueTests
|
|||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_ReentrantClearStopsTheCapturedBatch()
|
||||
{
|
||||
var queue = new OutboundInteractionQueue();
|
||||
var actual = new List<string>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string> 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<InteractionApproach> 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<string> Toasts = new();
|
||||
public readonly List<uint> Examines = new();
|
||||
public readonly List<PendingBackpackPlacement> PendingPlacements = new();
|
||||
public readonly List<PendingBackpackPlacement> 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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PendingBackpackPlacement>();
|
||||
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<PendingBackpackPlacement>();
|
||||
var cancelled = new List<PendingBackpackPlacement>();
|
||||
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<string>();
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ public class PaperdollControllerTests
|
|||
SelectionState? selection = null,
|
||||
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
List<string>? systemMessages = null)
|
||||
List<string>? systemMessages = null,
|
||||
Action<ItemInteractionController>? 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<string>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<ShortcutEntry>(),
|
||||
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<string>();
|
||||
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<ShortcutEntry>(),
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<uint>();
|
||||
var messages = new List<string>();
|
||||
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<UiButton>(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();
|
||||
|
|
|
|||
|
|
@ -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<WeenieError>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue