feat(items): port retail external-container looting
Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses. Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196. Release build succeeds and all 5,875 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
48a118db91
commit
20ce67b625
27 changed files with 1750 additions and 45 deletions
|
|
@ -2279,22 +2279,22 @@ R5/R6 touches the action list. **Where:**
|
|||
|
||||
---
|
||||
|
||||
## #196 — External-container lifecycle and retained window events are missing
|
||||
## #196 — External-container lifecycle and retained window events are missing — DONE 2026-07-17
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** CLOSED
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-02
|
||||
**Component:** ui / net
|
||||
|
||||
**Description:** `UiRoot`'s window registry flips `Visible` with no lifecycle event, toolbar-button sync is manually pushed at each mutation site, and acdream has no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to `ClientUISystem.groundObject`: replacing or closing an external container sends `0x0195` exactly once. The owned `gmInventoryUI` side-bag path has no corresponding static send.
|
||||
**Description:** `UiRoot`'s window registry originally flipped `Visible` with no lifecycle event, toolbar-button sync was manually pushed at each mutation site, and acdream had no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to replacing `ClientUISystem.groundObject`; the authored close/range path sends `Use(root)` and waits for server event `CloseGroundContainer (0x0052)`. Owned `gmInventoryUI` packs never send `0x0195`.
|
||||
|
||||
**Root cause / status:** PARTIAL 2026-07-11. Typed retained-window visibility events and subscriber-driven toolbar state shipped in Wave 4.4d, replacing manual sync at mutation sites. The issue remains open for the separate external-container owner whose ground-object replacement/close path must emit one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace.
|
||||
**Resolution:** Typed retained-window visibility and subscriber-driven toolbar state shipped in Wave 4.4d. The remaining external lifetime shipped 2026-07-17: Core `ExternalContainerState` owns expected/current root identity; App `ExternalContainerLifecycleController` emits exactly one `0x0195` only on root replacement; `GameEventWiring` accepts the expected root `ViewContents`, preserves nested snapshots, and retires the complete temporary projection tree on authoritative `0x0052`. `ExternalContainerController` mounts retail LayoutDesc `0x21000008`, including its horizontal lists/scrollbar, nested-container selection, exact close/range behavior, loot-to-pack, return-to-container, and partial-stack requests. Owned inventory no longer exposes any `0x0195` capability.
|
||||
|
||||
**Files:** `src/AcDream.App/UI/RetailWindowManager.cs`, `src/AcDream.App/UI/RetailUiRuntime.cs`; future external-container controller.
|
||||
**Files:** `src/AcDream.Core/Items/ExternalContainerState.cs`, `src/AcDream.App/World/ExternalContainerLifecycleController.cs`, `src/AcDream.App/UI/Layout/ExternalContainerController.cs`, `src/AcDream.Core.Net/GameEventWiring.cs`, `src/AcDream.Core/Items/ClientObjectTable.cs`.
|
||||
|
||||
**Research:** `docs/research/2026-07-02-ui-architecture-review.md`; correction oracle `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`.
|
||||
**Research:** `docs/research/2026-07-17-retail-external-container-looting-pseudocode.md`.
|
||||
|
||||
**Acceptance:** replacing/closing an external container sends `NoLongerViewingContents` exactly once; owned main/side-pack navigation and closing send nothing absent contrary trace evidence; lifecycle subscribers replace manual toolbar sync calls; repeat hiding is idempotent.
|
||||
**Acceptance:** replacement sends `NoLongerViewingContents` once; authored close/range sends `Use` once and waits for `0x0052`; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; looting and partial/full transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,14 @@ radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
|||
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
||||
must never become a competing selection owner.
|
||||
|
||||
External-container state follows the same ownership rule. Core
|
||||
`ExternalContainerState` owns the expected/current `ClientUISystem::groundObject`
|
||||
identity, Core.Net applies authoritative `ViewContents` and
|
||||
`CloseGroundContainer` events, and App `ExternalContainerLifecycleController`
|
||||
owns the replacement-only `NoLongerViewingContents` side effect. The retained
|
||||
`ExternalContainerController` is a projection over that state; mounting or
|
||||
disabling the gameplay UI cannot change the session lifetime.
|
||||
|
||||
Every retained gameplay window has a typed `RetailWindowHandle` and at most one
|
||||
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
|
||||
`RetainedPanelControllerGroup`; the manager disposes controllers exactly once in
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-101~~ | **RETIRED 2026-07-11 (Wave 4.4e)** — toolbar control coverage is complete: DAT panel launchers, combat, Use/Examine, selected-object strip, shortcuts, and exact thrown-weapon/separate-ammo count resolution on authored element `0x10000194`. | `src/AcDream.Core/Items/ToolbarAmmoPolicy.cs`; `src/AcDream.App/UI/Layout/ToolbarController.cs` | — | — | `gmToolbarUI::UpdateAmmoID @ 0x004BF210`; `UpdateAmmoNumber @ 0x004BE9E0` |
|
||||
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
|
||||
| AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||
| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
|
|||
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
|
||||
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
|
||||
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
|
||||
| B.5+ external-container looting | 2026-07-17. Retail `ClientUISystem::groundObject` lifetime plus `gmExternalContainerUI` LayoutDesc `0x21000008`: authoritative root/nested `ViewContents`, root-replacement `NoLongerViewingContents`, authored close/range `Use` → `CloseGroundContainer`, temporary projection-tree teardown, horizontal item lists/scrollbar, nested-container browsing, double-click loot, drag return, and partial-stack requests. Owned side packs no longer send the external-only packet. Research: [`docs/research/2026-07-17-retail-external-container-looting-pseudocode.md`](../research/2026-07-17-retail-external-container-looting-pseudocode.md). Closes #196 and retires AP-106. | Tests ✓; live visual gate pending |
|
||||
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
|
||||
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
|
||||
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` → `[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
# Retail external-container and looting pseudocode
|
||||
|
||||
Date: 2026-07-17
|
||||
|
||||
Scope: chests, corpses, and other world containers opened through `Use`; the
|
||||
bottom-of-screen external-container strip; looting into the player's carried
|
||||
inventory; returning carried items to the open external container; and the
|
||||
external-view close/replacement lifetime. Owned backpack and side-pack browsing
|
||||
is deliberately not part of this lifetime.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
Named Sept-2013 retail symbols:
|
||||
|
||||
- `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0`
|
||||
- `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0`
|
||||
- `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0`
|
||||
- `gmExternalContainerUI::PostInit @ 0x004CBDE0`
|
||||
- `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50`
|
||||
- `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0`
|
||||
- `gmExternalContainerUI::OnItemListDragOver @ 0x004CC0C0`
|
||||
- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
|
||||
- `ClientUISystem::SetGroundObject @ 0x00564510`
|
||||
- `ClientUISystem::OnViewContents @ 0x00565870`
|
||||
- `ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680`
|
||||
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
|
||||
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
|
||||
- `ItemHolder::UseObject @ 0x00588A80`
|
||||
|
||||
The exact client dispatch for server event `CloseGroundContainer (0x0052)` is
|
||||
at `0x0055B187` in `acclient_2013_pseudo_c.txt`.
|
||||
|
||||
Cross-references:
|
||||
|
||||
- ACE `GameEventViewContents`, `GameEventCloseGroundContainer`,
|
||||
`GameActionNoLongerViewingContents`, `Container.SendInventory`, and
|
||||
`Container.FinishClose` confirm the authoritative packet order and that ACE
|
||||
can send `ViewContents` for the root followed by its nested containers.
|
||||
- holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm
|
||||
the exact wire shapes. Its world inventory handler independently models open
|
||||
container contents as temporary preview retention and retires that preview on
|
||||
`CloseGroundContainer`.
|
||||
|
||||
## Authored UI
|
||||
|
||||
The strip is LayoutDesc `0x21000008`, selected root `0x10000063`
|
||||
(`0,400`, `800 x 110`) rather than a synthetic inventory window.
|
||||
|
||||
| Element | Purpose | Authored bounds |
|
||||
|---|---|---|
|
||||
| `0x10000064` | current root container item list | `5,5,36,36` |
|
||||
| `0x10000067` | nested-container selector list | `67,5,680,36` |
|
||||
| `0x10000068` | close button | `770,1,25,23` |
|
||||
| `0x1000006A` | current contents item list | `8,47,784,32` |
|
||||
| `0x1000006B` | horizontal contents scrollbar | `8,81,784,16` |
|
||||
|
||||
The close button uses sprites `0x060011BD` (normal) and `0x060011BC`
|
||||
(pressed). The horizontal scrollbar inherits the retail track, three-part
|
||||
thumb, and arrow sprites from LayoutDesc `0x2100003E`.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
UseWorldObject(objectId):
|
||||
decision = ItemHolder policy
|
||||
if decision is SetGroundObject:
|
||||
expectedExternalRoot = objectId
|
||||
send Use(objectId)
|
||||
|
||||
OnViewContents(containerId, orderedEntries):
|
||||
// Always update the projection: ACE sends nested-container snapshots too.
|
||||
objectTable.ReplaceContents(containerId, orderedEntries)
|
||||
|
||||
if containerId != expectedExternalRoot:
|
||||
return
|
||||
|
||||
old = currentExternalRoot
|
||||
if old != 0 and old != containerId:
|
||||
objectTable.StopViewingContentsTree(old)
|
||||
send NoLongerViewingContents(old) exactly once
|
||||
|
||||
currentExternalRoot = containerId
|
||||
expectedExternalRoot = containerId
|
||||
externalPanel.SetGroundObject(containerId)
|
||||
|
||||
ExternalPanel.SetGroundObject(containerId):
|
||||
unregister old range watch
|
||||
clear root, nested-container, and contents lists
|
||||
add root object to top list
|
||||
set current local container to root
|
||||
populate nested-container selector and current contents
|
||||
if containerId == 0:
|
||||
hide panel
|
||||
else:
|
||||
register range watch using root UseRadius
|
||||
show panel
|
||||
|
||||
ExternalPanel.CloseCurrentContainer():
|
||||
if currentExternalRoot == 0:
|
||||
return
|
||||
send Use(currentExternalRoot)
|
||||
// Do not send NoLongerViewingContents here. ACE responds with 0x0052.
|
||||
detach local lists and wait for authoritative close
|
||||
|
||||
OnCloseGroundContainer(containerId):
|
||||
if containerId != currentExternalRoot:
|
||||
return
|
||||
objectTable.StopViewingContentsTree(containerId)
|
||||
currentExternalRoot = 0
|
||||
expectedExternalRoot = 0
|
||||
externalPanel.SetGroundObject(0)
|
||||
|
||||
OnUseDone(error):
|
||||
if error != 0 and expectedExternalRoot != currentExternalRoot:
|
||||
expectedExternalRoot = currentExternalRoot
|
||||
|
||||
OnExternalRangeExit():
|
||||
externalPanel.SetVisible(false)
|
||||
ExternalPanel.CloseCurrentContainer()
|
||||
|
||||
OnExternalItemSingleClick(itemId):
|
||||
first offer it to target mode
|
||||
otherwise select the item
|
||||
|
||||
OnExternalItemDoubleClick(itemId):
|
||||
ItemHolder.UseObject(itemId)
|
||||
// Because item.ContainerId is currentExternalRoot, retail policy emits
|
||||
// PlaceInBackpack; the server remains authoritative for the move.
|
||||
|
||||
DragExternalItemToOwnedInventory(itemId, placement, splitAmount):
|
||||
if splitAmount < full stack:
|
||||
send StackableSplitToContainer(itemId, player/openOwnedBag,
|
||||
placement, splitAmount)
|
||||
else:
|
||||
send PutItemInContainer(itemId, player/openOwnedBag, placement)
|
||||
do not mutate canonical ownership before the server response
|
||||
|
||||
DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
|
||||
if splitAmount < full stack:
|
||||
send StackableSplitToContainer(itemId, currentExternalSubcontainer,
|
||||
placement, splitAmount)
|
||||
else:
|
||||
send PutItemInContainer(itemId, currentExternalSubcontainer, placement)
|
||||
do not mutate canonical ownership before the server response
|
||||
```
|
||||
|
||||
## Architectural mapping
|
||||
|
||||
- `ExternalContainerState` in Core owns only expected/current external-root
|
||||
identity and transition ordering. It filters ACE's nested `ViewContents`
|
||||
packets so they cannot replace the visible root.
|
||||
- `ExternalContainerLifecycleController` in App observes those transitions,
|
||||
retires the replaced root's temporary preview tree, then owns the
|
||||
replacement-only `NoLongerViewingContents` network side effect. The wire
|
||||
lifetime therefore does not depend on whether retained UI is mounted.
|
||||
- `ClientObjectTable` remains the canonical object and ordered-content
|
||||
projection owner.
|
||||
- `ExternalContainerController` owns the authored strip, range/close behavior,
|
||||
nested-list selection, selection, and drag/drop presentation.
|
||||
- `ItemInteractionController` remains the only interpreter of retail item-use
|
||||
and placement policy. It gains explicit adapters for `SetGroundObject` and
|
||||
`PlaceInContainer`; panels do not reimplement those decisions.
|
||||
- `GameWindow` only creates the lifecycle/UI bindings and supplies network
|
||||
delegates. No external-container feature body lives there.
|
||||
|
||||
There is no new intended retail divergence. Completing this mechanism retires
|
||||
AP-106/#196: `NoLongerViewingContents` belongs to replacement of an external
|
||||
root, not switching between owned side packs.
|
||||
|
|
@ -840,6 +840,8 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
|
||||
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
|
||||
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
|
||||
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
|
|
@ -2110,6 +2112,10 @@ public sealed class GameWindow : IDisposable
|
|||
_selection,
|
||||
autoTarget: () => _persistedGameplay.AutoTarget,
|
||||
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
|
||||
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
|
||||
_externalContainers,
|
||||
Objects,
|
||||
guid => _liveSession?.SendNoLongerViewingContents(guid));
|
||||
|
||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||
if (_options.RetailUi)
|
||||
|
|
@ -2146,7 +2152,11 @@ public sealed class GameWindow : IDisposable
|
|||
stackSplitQuantity: _stackSplitQuantity,
|
||||
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
sendSplitToContainer: (item, container, placement, amount) =>
|
||||
_liveSession?.SendStackableSplitToContainer(
|
||||
item, container, placement, amount),
|
||||
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2358,7 +2368,6 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
|
||||
is { } strength ? (int?)strength.Current : null,
|
||||
guid => _liveSession?.SendUse(guid),
|
||||
guid => _liveSession?.SendNoLongerViewingContents(guid),
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
(item, container, placement, amount) =>
|
||||
|
|
@ -2368,6 +2377,22 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession?.SendStackableMerge(source, target, amount),
|
||||
_itemInteractionController,
|
||||
_selection),
|
||||
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
|
||||
_externalContainers,
|
||||
Objects,
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetDragIcon(type, icon, under, over, effects),
|
||||
_itemInteractionController,
|
||||
_selection,
|
||||
guid => _liveSession?.SendUse(guid),
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
(item, container, placement, amount) =>
|
||||
_liveSession?.SendStackableSplitToContainer(
|
||||
item, container, placement, amount),
|
||||
IsWithinExternalContainerUseRange),
|
||||
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
||||
cursorFeedbackController,
|
||||
retailCursorManager),
|
||||
|
|
@ -2863,6 +2888,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Attachment projections own GL-backed world registrations, so tear
|
||||
// them down before dropping the canonical GUID/timestamp snapshots.
|
||||
_equippedChildRenderer?.Clear();
|
||||
_externalContainers.Reset();
|
||||
Objects.Clear();
|
||||
SpellBook.Clear();
|
||||
_magicRuntime?.Reset();
|
||||
|
|
@ -3025,7 +3051,11 @@ public sealed class GameWindow : IDisposable
|
|||
},
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error => _itemInteractionController?.CompleteUse(error),
|
||||
onUseDone: error =>
|
||||
{
|
||||
_externalContainers.ApplyUseDone(error);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||
|
|
@ -3038,7 +3068,8 @@ public sealed class GameWindow : IDisposable
|
|||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
clientTime: ClientTimerNow);
|
||||
clientTime: ClientTimerNow,
|
||||
externalContainers: _externalContainers);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -13266,6 +13297,9 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
if (IsExternalContainerTarget(guid))
|
||||
_externalContainers.RequestOpen(guid);
|
||||
|
||||
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
|
||||
// through the player's MoveToManager so close-range Use rotates the
|
||||
// body to face before the action fires. For FAR targets, ACE's
|
||||
|
|
@ -13482,6 +13516,40 @@ public sealed class GameWindow : IDisposable
|
|||
return distSq <= useRadius * useRadius;
|
||||
}
|
||||
|
||||
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
||||
{
|
||||
if (_playerController is null
|
||||
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
||||
{
|
||||
// The server remains authoritative for forced close. An entity can be
|
||||
// temporarily absent from the visible-cell projection while its external
|
||||
// view is still valid; do not manufacture a range exit from missing render state.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
|| spawn.UseRadius is not > 0f)
|
||||
return true;
|
||||
float useRadius = spawn.UseRadius.Value;
|
||||
float dx = entity.Position.X - _playerController.Position.X;
|
||||
float dy = entity.Position.Y - _playerController.Position.Y;
|
||||
return dx * dx + dy * dy <= useRadius * useRadius;
|
||||
}
|
||||
|
||||
private bool IsExternalContainerTarget(uint guid)
|
||||
{
|
||||
if (Objects.Get(guid) is { } item
|
||||
&& (item.ContainerTypeHint != 0u
|
||||
|| item.Type.HasFlag(AcDream.Core.Items.ItemType.Container)
|
||||
|| item.ItemsCapacity > 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return LastSpawns.TryGetValue(guid, out var spawn)
|
||||
&& (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u;
|
||||
}
|
||||
|
||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||||
{
|
||||
if (_playerController is not { } pc || pc.MoveTo is null) return;
|
||||
|
|
@ -14784,6 +14852,8 @@ public sealed class GameWindow : IDisposable
|
|||
_uiHost = null;
|
||||
_itemInteractionController?.Dispose();
|
||||
_itemInteractionController = null;
|
||||
_externalContainerLifecycle?.Dispose();
|
||||
_externalContainerLifecycle = null;
|
||||
_magicRuntime = null;
|
||||
_magicCatalog = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
private readonly Action<uint, uint>? _sendSplitToWorld;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendGive;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<bool> _readyForInventoryRequest;
|
||||
|
|
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Func<bool> _inNonCombatMode;
|
||||
private readonly Func<uint, bool> _isComponentPack;
|
||||
private readonly Action<uint>? _placeInBackpack;
|
||||
private readonly Action<uint>? _requestExternalContainer;
|
||||
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
||||
private readonly InteractionState _interactionState;
|
||||
private readonly Func<uint> _selectedObjectId;
|
||||
|
|
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint>? sendGive = null,
|
||||
Func<bool>? dragOnPlayerOpensSecureTrade = null,
|
||||
Action<string>? systemMessage = null)
|
||||
Action<string>? systemMessage = null,
|
||||
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
|
||||
Action<uint>? requestExternalContainer = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendWield = sendWield;
|
||||
_sendDrop = sendDrop;
|
||||
_sendSplitToWorld = sendSplitToWorld;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendSplitToContainer = sendSplitToContainer;
|
||||
_sendGive = sendGive;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
|
|
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_inNonCombatMode = inNonCombatMode ?? (() => false);
|
||||
_isComponentPack = isComponentPack ?? (_ => false);
|
||||
_placeInBackpack = placeInBackpack;
|
||||
_requestExternalContainer = requestExternalContainer;
|
||||
_auxiliaryAction = auxiliaryAction;
|
||||
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
||||
_stackSplitQuantity = stackSplitQuantity;
|
||||
|
|
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
|
||||
acted |= _sendUseWithTarget is not null;
|
||||
break;
|
||||
case ItemPolicyActionKind.SetGroundObject:
|
||||
_requestExternalContainer?.Invoke(action.ObjectId);
|
||||
acted |= _requestExternalContainer is not null;
|
||||
break;
|
||||
case ItemPolicyActionKind.EnterTargetMode:
|
||||
EnterTargetMode(action.ObjectId);
|
||||
acted = true;
|
||||
|
|
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
|
|||
action.ObjectId,
|
||||
(uint)Math.Max(1, action.Amount));
|
||||
break;
|
||||
case ItemPolicyActionKind.PlaceInContainer:
|
||||
{
|
||||
uint fullStack = (uint)Math.Max(
|
||||
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);
|
||||
break;
|
||||
}
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
|
|
|
|||
514
src/AcDream.App/UI/Layout/ExternalContainerController.cs
Normal file
514
src/AcDream.App/UI/Layout/ExternalContainerController.cs
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
using System;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmExternalContainerUI</c> controller for LayoutDesc 0x21000008.
|
||||
/// Chests, corpses, and other world containers share this bottom-screen strip;
|
||||
/// it is intentionally independent from the owned backpack window.
|
||||
/// </summary>
|
||||
public sealed class ExternalContainerController : IItemListDragHandler, IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000008u;
|
||||
public const uint RootId = 0x10000063u;
|
||||
public const uint TopContainerId = 0x10000064u;
|
||||
public const uint ContainerListId = 0x10000067u;
|
||||
public const uint CloseButtonId = 0x10000068u;
|
||||
public const uint ContentsListId = 0x1000006Au;
|
||||
public const uint ContentsScrollbarId = 0x1000006Bu;
|
||||
|
||||
private const float ItemCellSize = 32f;
|
||||
private const float ContainerCellSize = 36f;
|
||||
|
||||
private readonly ExternalContainerState _state;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly ItemInteractionController _itemInteraction;
|
||||
private readonly StackSplitQuantityState _stackSplitQuantity;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveDragIcon;
|
||||
private readonly Action<uint> _sendUse;
|
||||
private readonly Action<uint, uint, int> _sendPutItemInContainer;
|
||||
private readonly Action<uint, uint, uint, uint> _sendSplitToContainer;
|
||||
private readonly Func<uint, bool> _isWithinUseRange;
|
||||
private readonly RetailWindowHandle _window;
|
||||
private readonly UiItemList _topContainer;
|
||||
private readonly UiItemList _containerList;
|
||||
private readonly UiItemList _contentsList;
|
||||
|
||||
private uint _openContainer;
|
||||
private bool _closeRequested;
|
||||
private bool _disposed;
|
||||
|
||||
private ExternalContainerController(
|
||||
ImportedLayout layout,
|
||||
ExternalContainerState state,
|
||||
ClientObjectTable objects,
|
||||
SelectionState selection,
|
||||
ItemInteractionController itemInteraction,
|
||||
StackSplitQuantityState stackSplitQuantity,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
|
||||
Action<uint> sendUse,
|
||||
Action<uint, uint, int> sendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint> sendSplitToContainer,
|
||||
Func<uint, bool> isWithinUseRange,
|
||||
RetailWindowHandle window,
|
||||
uint contentsEmptySprite,
|
||||
uint containerEmptySprite)
|
||||
{
|
||||
_state = state;
|
||||
_objects = objects;
|
||||
_selection = selection;
|
||||
_itemInteraction = itemInteraction;
|
||||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_resolveIcon = resolveIcon;
|
||||
_resolveDragIcon = resolveDragIcon;
|
||||
_sendUse = sendUse;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendSplitToContainer = sendSplitToContainer;
|
||||
_isWithinUseRange = isWithinUseRange;
|
||||
_window = window;
|
||||
|
||||
_topContainer = RequiredList(layout, TopContainerId);
|
||||
_containerList = RequiredList(layout, ContainerListId);
|
||||
_contentsList = RequiredList(layout, ContentsListId);
|
||||
|
||||
ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite);
|
||||
ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite);
|
||||
ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite);
|
||||
_contentsList.RegisterDragHandler(this);
|
||||
|
||||
if (layout.FindElement(ContentsScrollbarId) is UiScrollbar scrollbar)
|
||||
{
|
||||
scrollbar.Model = _contentsList.Scroll;
|
||||
scrollbar.Horizontal = true;
|
||||
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
|
||||
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
|
||||
// factory treats all horizontal bars as scalar controls; this panel
|
||||
// binds the authored model sprites explicitly.
|
||||
scrollbar.TrackSprite = 0x06004C7Fu;
|
||||
scrollbar.ThumbTopSprite = 0x06004C80u;
|
||||
scrollbar.ThumbSprite = 0x06004C83u;
|
||||
scrollbar.ThumbBotSprite = 0x06004C86u;
|
||||
scrollbar.DownSprite = 0x06004C89u;
|
||||
scrollbar.UpSprite = 0x06004C8Cu;
|
||||
}
|
||||
|
||||
BindClose(layout, RequestClose);
|
||||
|
||||
_state.Changed += OnExternalContainerChanged;
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ContainerContentsReplaced += OnContentsReplaced;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
ClearLists();
|
||||
}
|
||||
|
||||
public static ExternalContainerController Bind(
|
||||
ImportedLayout layout,
|
||||
ExternalContainerState state,
|
||||
ClientObjectTable objects,
|
||||
SelectionState selection,
|
||||
ItemInteractionController itemInteraction,
|
||||
StackSplitQuantityState stackSplitQuantity,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
|
||||
Action<uint> sendUse,
|
||||
Action<uint, uint, int> sendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint> sendSplitToContainer,
|
||||
Func<uint, bool> isWithinUseRange,
|
||||
RetailWindowHandle window,
|
||||
uint contentsEmptySprite = 0u,
|
||||
uint containerEmptySprite = 0u)
|
||||
=> new(
|
||||
layout,
|
||||
state,
|
||||
objects,
|
||||
selection,
|
||||
itemInteraction,
|
||||
stackSplitQuantity,
|
||||
resolveIcon,
|
||||
resolveDragIcon,
|
||||
sendUse,
|
||||
sendPutItemInContainer,
|
||||
sendSplitToContainer,
|
||||
isWithinUseRange,
|
||||
window,
|
||||
contentsEmptySprite,
|
||||
containerEmptySprite);
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
uint root = _state.CurrentContainerId;
|
||||
if (root != 0u && _window.IsVisible && !_closeRequested && !_isWithinUseRange(root))
|
||||
RequestClose();
|
||||
}
|
||||
|
||||
public void RequestClose()
|
||||
{
|
||||
uint root = _state.CurrentContainerId;
|
||||
if (root == 0u || _closeRequested)
|
||||
return;
|
||||
|
||||
// gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0 sends
|
||||
// Use(root), detaches the lists, and waits for event 0x0052. It does not
|
||||
// send NoLongerViewingContents on this path.
|
||||
_closeRequested = true;
|
||||
_window.Hide();
|
||||
ClearLists();
|
||||
_sendUse(root);
|
||||
}
|
||||
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.ObjId != 0u)
|
||||
_selection.Select(payload.ObjId, SelectionChangeSource.ExternalContainer);
|
||||
}
|
||||
|
||||
public ItemDragAcceptance OnDragOver(
|
||||
UiItemList targetList,
|
||||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (!ReferenceEquals(targetList, _contentsList)
|
||||
|| payload.SourceKind == ItemDragSource.ShortcutBar
|
||||
|| payload.ObjId == 0u
|
||||
|| _openContainer == 0u
|
||||
|| payload.ObjId == _openContainer)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return ItemDragAcceptance.Accept;
|
||||
}
|
||||
|
||||
public void HandleDropRelease(
|
||||
UiItemList targetList,
|
||||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
||||
return;
|
||||
if (_objects.Get(payload.ObjId) is not { } item)
|
||||
return;
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
uint amount = _stackSplitQuantity.GetObjectSplitSize(
|
||||
item.ObjectId,
|
||||
_selection.SelectedObjectId ?? 0u,
|
||||
fullStack);
|
||||
int placement = targetCell.ItemId != 0u
|
||||
? Math.Max(0, targetCell.SlotIndex)
|
||||
: _objects.GetContents(_openContainer).Count;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
private void OnExternalContainerChanged(ExternalContainerTransition transition)
|
||||
{
|
||||
if (transition.ContainerId == 0u)
|
||||
{
|
||||
_openContainer = 0u;
|
||||
_closeRequested = false;
|
||||
_window.Hide();
|
||||
ClearLists();
|
||||
return;
|
||||
}
|
||||
|
||||
_openContainer = transition.ContainerId;
|
||||
_closeRequested = false;
|
||||
ScrollToHome();
|
||||
Populate();
|
||||
_window.Show();
|
||||
}
|
||||
|
||||
private void Populate()
|
||||
{
|
||||
uint root = _state.CurrentContainerId;
|
||||
if (root == 0u)
|
||||
{
|
||||
ClearLists();
|
||||
return;
|
||||
}
|
||||
if (_openContainer == 0u)
|
||||
_openContainer = root;
|
||||
|
||||
using IDisposable topLayout = _topContainer.DeferLayout();
|
||||
using IDisposable containerLayout = _containerList.DeferLayout();
|
||||
using IDisposable contentsLayout = _contentsList.DeferLayout();
|
||||
_topContainer.Flush();
|
||||
_containerList.Flush();
|
||||
_contentsList.Flush();
|
||||
|
||||
AddRootCell(root);
|
||||
foreach (uint guid in _objects.GetContents(root))
|
||||
{
|
||||
if (IsContainer(_objects.Get(guid)))
|
||||
AddContainerCell(guid);
|
||||
}
|
||||
|
||||
foreach (uint guid in _objects.GetContents(_openContainer))
|
||||
{
|
||||
if (!IsContainer(_objects.Get(guid)))
|
||||
AddContentsCell(guid);
|
||||
}
|
||||
ApplyIndicators();
|
||||
}
|
||||
|
||||
private void AddRootCell(uint guid)
|
||||
{
|
||||
UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground);
|
||||
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
|
||||
cell.DoubleClicked = RequestClose;
|
||||
cell.IsOpenContainer = _openContainer == guid;
|
||||
SetCapacity(cell, guid);
|
||||
_topContainer.AddItem(cell);
|
||||
}
|
||||
|
||||
private void AddContainerCell(uint guid)
|
||||
{
|
||||
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
|
||||
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenNestedContainer(guid));
|
||||
SetCapacity(cell, guid);
|
||||
_containerList.AddItem(cell);
|
||||
}
|
||||
|
||||
private void AddContentsCell(uint guid)
|
||||
{
|
||||
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
|
||||
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
|
||||
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
|
||||
cell.DragAcceptSprite = 0x060011F9u;
|
||||
cell.DragRejectSprite = 0x060011F8u;
|
||||
_contentsList.AddItem(cell);
|
||||
}
|
||||
|
||||
private UiItemSlot CreateCell(UiItemList owner, uint guid, ItemDragSource source)
|
||||
{
|
||||
ClientObject? item = _objects.Get(guid);
|
||||
uint icon = item is null ? 0u : _resolveIcon(
|
||||
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
uint dragIcon = item is null ? 0u : _resolveDragIcon(
|
||||
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = owner.SpriteResolve,
|
||||
SlotIndex = owner.GetNumUIItems(),
|
||||
SourceKind = source,
|
||||
};
|
||||
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private void OpenNestedContainer(uint guid)
|
||||
{
|
||||
Select(guid);
|
||||
_openContainer = guid;
|
||||
_contentsList.Scroll.SetScrollY(0);
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void Select(uint guid)
|
||||
=> _selection.Select(guid, SelectionChangeSource.ExternalContainer);
|
||||
|
||||
private void HandlePrimaryClick(uint guid, Action fallback)
|
||||
{
|
||||
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
|
||||
return;
|
||||
fallback();
|
||||
}
|
||||
|
||||
private void ApplyIndicators()
|
||||
{
|
||||
ApplyIndicators(_topContainer);
|
||||
ApplyIndicators(_containerList);
|
||||
ApplyIndicators(_contentsList);
|
||||
}
|
||||
|
||||
private void ApplyIndicators(UiItemList list)
|
||||
{
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
{
|
||||
if (list.GetItem(i) is not { } cell) continue;
|
||||
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
|
||||
cell.Selected = cell.ItemId != 0u
|
||||
&& cell.ItemId == _selection.SelectedObjectId
|
||||
&& !pendingSource;
|
||||
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCapacity(UiItemSlot cell, uint containerId)
|
||||
{
|
||||
int capacity = _objects.Get(containerId)?.ItemsCapacity ?? 0;
|
||||
cell.CapacityFill = capacity <= 0
|
||||
? -1f
|
||||
: Math.Clamp(_objects.GetContents(containerId).Count / (float)capacity, 0f, 1f);
|
||||
}
|
||||
|
||||
private void ClearLists()
|
||||
{
|
||||
_topContainer.Flush();
|
||||
_containerList.Flush();
|
||||
_contentsList.Flush();
|
||||
ScrollToHome();
|
||||
}
|
||||
|
||||
private void ScrollToHome()
|
||||
{
|
||||
_topContainer.Scroll.SetScrollY(0);
|
||||
_containerList.Scroll.SetScrollY(0);
|
||||
_contentsList.Scroll.SetScrollY(0);
|
||||
}
|
||||
|
||||
private void OnObjectChanged(ClientObject item)
|
||||
{
|
||||
if (_window.IsVisible && Concerns(item))
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (!_window.IsVisible) return;
|
||||
uint root = _state.CurrentContainerId;
|
||||
if ((move.Item is { } item && Concerns(item))
|
||||
|| move.Previous.ContainerId == root
|
||||
|| move.Current.ContainerId == root
|
||||
|| move.Previous.ContainerId == _openContainer
|
||||
|| move.Current.ContainerId == _openContainer)
|
||||
{
|
||||
Populate();
|
||||
}
|
||||
}
|
||||
private void OnContentsReplaced(uint containerId)
|
||||
{
|
||||
if (_window.IsVisible
|
||||
&& (containerId == _state.CurrentContainerId
|
||||
|| containerId == _openContainer
|
||||
|| ProjectionContains(_state.CurrentContainerId, containerId)))
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (_selection.SelectedObjectId == item.ObjectId)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
|
||||
if (item.ObjectId == _state.CurrentContainerId)
|
||||
_state.ApplyClose(item.ObjectId);
|
||||
else if (_window.IsVisible && Concerns(item))
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
_openContainer = 0u;
|
||||
_closeRequested = false;
|
||||
_window.Hide();
|
||||
ClearLists();
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
|
||||
private static bool IsContainer(ClientObject? item)
|
||||
=> item is not null
|
||||
&& (item.ContainerTypeHint != 0u
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0);
|
||||
|
||||
private bool Concerns(ClientObject item)
|
||||
{
|
||||
uint root = _state.CurrentContainerId;
|
||||
return item.ObjectId == root
|
||||
|| item.ObjectId == _openContainer
|
||||
|| item.ContainerId == root
|
||||
|| item.ContainerId == _openContainer
|
||||
|| ProjectionContains(root, item.ObjectId)
|
||||
|| ProjectionContains(_openContainer, item.ObjectId);
|
||||
}
|
||||
|
||||
private bool ProjectionContains(uint containerId, uint itemId)
|
||||
{
|
||||
if (containerId == 0u || itemId == 0u) return false;
|
||||
foreach (uint candidate in _objects.GetContents(containerId))
|
||||
{
|
||||
if (candidate == itemId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ConfigureList(
|
||||
UiItemList list,
|
||||
float cellSize,
|
||||
bool horizontalScroll,
|
||||
uint emptySprite)
|
||||
{
|
||||
list.Columns = 1;
|
||||
list.CellWidth = cellSize;
|
||||
list.CellHeight = cellSize;
|
||||
list.SingleRow = true;
|
||||
list.HorizontalScroll = horizontalScroll;
|
||||
list.FillVisibleEmptySlots = true;
|
||||
if (emptySprite != 0u)
|
||||
list.CellEmptySprite = emptySprite;
|
||||
list.EmptySlotFactory = () => new UiItemSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SourceKind = ItemDragSource.Ground,
|
||||
DragAcceptSprite = 0x060011F9u,
|
||||
DragRejectSprite = 0x060011F8u,
|
||||
};
|
||||
}
|
||||
|
||||
private static UiItemList RequiredList(ImportedLayout layout, uint id)
|
||||
=> layout.FindElement(id) as UiItemList
|
||||
?? throw new InvalidOperationException(
|
||||
$"External-container LayoutDesc is missing ItemList 0x{id:X8}.");
|
||||
|
||||
private static void BindClose(ImportedLayout layout, Action close)
|
||||
{
|
||||
switch (layout.FindElement(CloseButtonId))
|
||||
{
|
||||
case UiButton button:
|
||||
button.OnClick = close;
|
||||
break;
|
||||
case UiDatElement element:
|
||||
element.ClickThrough = false;
|
||||
element.OnClick = close;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"External-container LayoutDesc is missing its close button.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_state.Changed -= OnExternalContainerChanged;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ContainerContentsReplaced -= OnContentsReplaced;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
|
|
@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
|
||||
Action<uint, uint, uint>? sendStackableMerge,
|
||||
|
|
@ -100,7 +98,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_strength = strength;
|
||||
_ownerName = ownerName;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendStackableSplitToContainer = sendStackableSplitToContainer;
|
||||
_sendStackableMerge = sendStackableMerge;
|
||||
|
|
@ -212,7 +209,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
|
||||
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||
|
|
@ -224,7 +220,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
|
||||
ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
||||
sendUse, sendPutItemInContainer,
|
||||
sendStackableSplitToContainer, sendStackableMerge,
|
||||
notifyMergeAttempt, itemInteraction,
|
||||
onClose, stackSplitQuantity);
|
||||
|
|
@ -529,7 +525,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
}
|
||||
|
||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||
// External-container contents are a temporary ViewContents projection.
|
||||
// Retail UIAttemptPutInContainer leaves them untouched until the server's
|
||||
// authoritative move response; only rearrangements within owned inventory
|
||||
// use the existing optimistic projection.
|
||||
if (payload.SourceKind != ItemDragSource.Ground)
|
||||
_objects.MoveItemOptimistic(item, container, placement);
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
|
|
@ -588,8 +589,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
|
||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
|
||||
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async).
|
||||
/// Owned packs are not ClientUISystem::groundObject and never send
|
||||
/// NoLongerViewingContents. Retail:
|
||||
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
|
||||
private void OpenContainer(uint guid)
|
||||
{
|
||||
|
|
@ -599,8 +601,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||
|
||||
uint p = _playerGuid();
|
||||
if (open != p && open != 0)
|
||||
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
|
||||
_openContainer = guid;
|
||||
if (guid != p)
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
|
|
|
|||
|
|
@ -118,13 +118,24 @@ public sealed record InventoryRuntimeBindings(
|
|||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
Func<int?> Strength,
|
||||
Action<uint>? SendUse,
|
||||
Action<uint>? SendNoLongerViewing,
|
||||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
|
||||
Action<uint, uint, uint>? SendStackableMerge,
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection);
|
||||
|
||||
public sealed record ExternalContainerRuntimeBindings(
|
||||
ExternalContainerState State,
|
||||
ClientObjectTable Objects,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection,
|
||||
Action<uint> SendUse,
|
||||
Action<uint, uint, int> SendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint> SendStackableSplitToContainer,
|
||||
Func<uint, bool> IsWithinUseRange);
|
||||
|
||||
public sealed record RetailUiPersistenceBindings(
|
||||
SettingsStore Store,
|
||||
Func<string> CharacterKey,
|
||||
|
|
@ -157,6 +168,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ToolbarRuntimeBindings Toolbar,
|
||||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
ExternalContainerRuntimeBindings ExternalContainer,
|
||||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
|
|
@ -204,6 +216,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
MountExternalContainer();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -215,7 +228,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
persistence.Store,
|
||||
persistence.CharacterKey,
|
||||
persistence.ScreenSize,
|
||||
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
|
||||
stateManagedVisibilityWindows:
|
||||
[
|
||||
WindowNames.Combat,
|
||||
WindowNames.JumpPowerbar,
|
||||
WindowNames.ExternalContainer,
|
||||
]);
|
||||
}
|
||||
|
||||
if (bindings.Probe.Enabled)
|
||||
|
|
@ -257,6 +275,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -283,6 +302,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -1577,7 +1597,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
InventoryController inventory = InventoryController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||
contents, sideBag, mainPack, b.SendUse,
|
||||
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
|
||||
notifyMergeAttempt, b.ItemInteraction,
|
||||
() => CloseWindow(WindowNames.Inventory),
|
||||
|
|
@ -1596,6 +1616,83 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
|
||||
private void MountExternalContainer()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[D.2b] external container: LayoutDesc 0x21000008 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.ExternalContainer,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = root.Left,
|
||||
Top = root.Top,
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
});
|
||||
|
||||
uint contentsEmpty;
|
||||
uint containerEmpty;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.ContentsListId);
|
||||
containerEmpty = ItemListCellTemplate.ResolveEmptySprite(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.ContainerListId);
|
||||
}
|
||||
|
||||
ExternalContainerRuntimeBindings b = _bindings.ExternalContainer;
|
||||
ExternalContainerController = ExternalContainerController.Bind(
|
||||
layout,
|
||||
b.State,
|
||||
b.Objects,
|
||||
b.Selection,
|
||||
b.ItemInteraction,
|
||||
StackSplitQuantity,
|
||||
b.ResolveIcon,
|
||||
b.ResolveDragIcon,
|
||||
b.SendUse,
|
||||
b.SendPutItemInContainer,
|
||||
b.SendStackableSplitToContainer,
|
||||
b.IsWithinUseRange,
|
||||
handle,
|
||||
contentsEmpty,
|
||||
containerEmpty);
|
||||
Host.WindowManager.AttachController(
|
||||
WindowNames.ExternalContainer,
|
||||
ExternalContainerController);
|
||||
Console.WriteLine(
|
||||
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement
|
|||
/// </summary>
|
||||
public bool SingleRow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The single row uses the shared pixel scroll model as a horizontal offset.
|
||||
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
|
||||
/// horizontal ItemList + Scrollbar consumer.
|
||||
/// </summary>
|
||||
public bool HorizontalScroll { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
|
||||
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
|
||||
|
|
@ -206,6 +213,28 @@ public sealed class UiItemList : UiElement
|
|||
: Columns < 1 ? 1 : Columns;
|
||||
int cellH = (int)MathF.Round(CellHeight);
|
||||
|
||||
if (SingleRow && HorizontalScroll)
|
||||
{
|
||||
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
|
||||
Scroll.LineHeight = cellW;
|
||||
Scroll.ContentHeight = _cells.Count * cellW;
|
||||
Scroll.ViewHeight = (int)MathF.Floor(Width);
|
||||
Scroll.SetScrollY(Scroll.ScrollY);
|
||||
float scrollX = Scroll.ScrollY;
|
||||
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
float left = i * CellWidth - scrollX;
|
||||
UiItemSlot cell = _cells[i];
|
||||
cell.Left = left;
|
||||
cell.Top = 0f;
|
||||
cell.Width = CellWidth;
|
||||
cell.Height = CellHeight;
|
||||
cell.Visible = left < Width && left + CellWidth > 0f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
|
||||
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
|
||||
// so the clamp uses the right max.
|
||||
|
|
@ -292,6 +321,16 @@ public sealed class UiItemList : UiElement
|
|||
public void ScrollItemIntoView(int index)
|
||||
{
|
||||
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
|
||||
if (SingleRow && HorizontalScroll)
|
||||
{
|
||||
float left = index * CellWidth;
|
||||
float right = left + CellWidth;
|
||||
if (left < Scroll.ScrollY)
|
||||
Scroll.SetScrollY((int)MathF.Floor(left));
|
||||
else if (right > Scroll.ScrollY + Width)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
|
||||
return;
|
||||
}
|
||||
int columns = Math.Max(1, Columns);
|
||||
int row = Flow == UiItemListFlow.ColumnMajor
|
||||
? index % Math.Max(1, RowCount(_cells.Count, columns))
|
||||
|
|
|
|||
|
|
@ -141,6 +141,11 @@ public sealed class UiScrollbar : UiElement
|
|||
if (SpriteResolve is not { } resolve) return;
|
||||
if (Horizontal)
|
||||
{
|
||||
if (ScalarChanged is null && Model is { } horizontalModel)
|
||||
{
|
||||
DrawHorizontalModel(ctx, resolve, horizontalModel);
|
||||
return;
|
||||
}
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
(float rangeLeft, float rangeWidth) = ScalarRangeRect();
|
||||
if (ScalarRangeSprite != 0)
|
||||
|
|
@ -196,6 +201,31 @@ public sealed class UiScrollbar : UiElement
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawHorizontalModel(
|
||||
UiRenderContext ctx,
|
||||
Func<uint, (uint tex, int w, int h)> resolve,
|
||||
UiScrollable model)
|
||||
{
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
|
||||
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
|
||||
if (!model.HasOverflow) return;
|
||||
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
|
|
@ -263,6 +293,9 @@ public sealed class UiScrollbar : UiElement
|
|||
if (Horizontal && ScalarChanged is not null)
|
||||
return OnScalarEvent(e);
|
||||
|
||||
if (Horizontal && Model is not null)
|
||||
return OnHorizontalModelEvent(e);
|
||||
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
switch (e.Type)
|
||||
|
|
@ -318,6 +351,50 @@ public sealed class UiScrollbar : UiElement
|
|||
return false;
|
||||
}
|
||||
|
||||
private bool OnHorizontalModelEvent(in UiEvent e)
|
||||
{
|
||||
UiScrollable m = Model!;
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
float x = e.Data1;
|
||||
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
|
||||
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
|
||||
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
|
||||
if (x >= tx && x <= tx + tw)
|
||||
{
|
||||
_draggingThumb = true;
|
||||
_dragOffsetX = x - tx;
|
||||
}
|
||||
else
|
||||
{
|
||||
m.ScrollByPage(x < tx ? -1 : 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove when _draggingThumb:
|
||||
{
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
|
||||
float travel = MathF.Max(1f, trackLength - thumbWidth);
|
||||
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
|
||||
m.SetPositionRatio(ratio);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool OnScalarEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public static class WindowNames
|
|||
public const string Character = "character";
|
||||
public const string CharacterInformation = "character-information";
|
||||
public const string Inventory = "inventory";
|
||||
public const string ExternalContainer = "external-container";
|
||||
public const string Chat = "chat";
|
||||
public const string Radar = "radar";
|
||||
public const string Combat = "combat";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the network side effect of replacing retail's current ground object.
|
||||
/// Presentation controllers observe the same state but do not own session
|
||||
/// lifecycle messages.
|
||||
/// </summary>
|
||||
public sealed class ExternalContainerLifecycleController : IDisposable
|
||||
{
|
||||
private readonly ExternalContainerState _state;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Action<uint> _sendNoLongerViewingContents;
|
||||
private bool _disposed;
|
||||
|
||||
public ExternalContainerLifecycleController(
|
||||
ExternalContainerState state,
|
||||
ClientObjectTable objects,
|
||||
Action<uint> sendNoLongerViewingContents)
|
||||
{
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_sendNoLongerViewingContents = sendNoLongerViewingContents
|
||||
?? throw new ArgumentNullException(nameof(sendNoLongerViewingContents));
|
||||
_state.Changed += OnChanged;
|
||||
}
|
||||
|
||||
private void OnChanged(ExternalContainerTransition transition)
|
||||
{
|
||||
// ClientUISystem::SetGroundObject @ 0x00564510 always queues the old
|
||||
// root's contents tree for destruction when the ground object changes.
|
||||
if (transition.PreviousContainerId != 0u)
|
||||
_objects.StopViewingContentsTree(transition.PreviousContainerId);
|
||||
|
||||
// Only replacement emits Event_NoLongerViewingContents. The authored
|
||||
// close/range path instead sends Use(root) and waits for event 0x0052.
|
||||
if (transition.Kind == ExternalContainerTransitionKind.Replaced
|
||||
&& transition.PreviousContainerId != 0u)
|
||||
{
|
||||
_sendNoLongerViewingContents(transition.PreviousContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_state.Changed -= OnChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,8 @@ public static class GameEventWiring
|
|||
SquelchState? squelch = null,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||
Func<double>? clientTime = null)
|
||||
Func<double>? clientTime = null,
|
||||
ExternalContainerState? externalContainers = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -364,6 +365,7 @@ public static class GameEventWiring
|
|||
p.Value.Items[i].Guid,
|
||||
p.Value.Items[i].ContainerType);
|
||||
items.ReplaceContents(p.Value.ContainerGuid, entries);
|
||||
externalContainers?.ApplyViewContents(p.Value.ContainerGuid);
|
||||
});
|
||||
|
||||
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
|
||||
|
|
@ -412,13 +414,15 @@ public static class GameEventWiring
|
|||
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
|
||||
});
|
||||
|
||||
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
|
||||
// view. No table change (the view is UI-only, wired in container-open); log.
|
||||
// CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and
|
||||
// retire the root plus nested temporary ViewContents projections. Child
|
||||
// objects remain until authoritative move/delete wire says otherwise.
|
||||
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
|
||||
{
|
||||
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
|
||||
if (guid is not null)
|
||||
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
|
||||
if (guid is null) return;
|
||||
externalContainers?.ApplyClose(guid.Value);
|
||||
items.StopViewingContentsTree(guid.Value);
|
||||
});
|
||||
|
||||
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
|
||||
|
|
|
|||
|
|
@ -1026,6 +1026,56 @@ public sealed class ClientObjectTable
|
|||
ContainerContentsReplaced?.Invoke(containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retires a viewed container's temporary ordered projection without inventing
|
||||
/// item movement or deleting its child objects. Retail
|
||||
/// <c>ACCObjectMaint::StopViewingObjectContents</c> is driven by server event
|
||||
/// CloseGroundContainer; canonical ownership remains owned by move/delete wire.
|
||||
/// </summary>
|
||||
public bool StopViewingContents(uint containerId)
|
||||
{
|
||||
if (containerId == 0u || !_containerIndex.Remove(containerId))
|
||||
return false;
|
||||
ContainerContentsReplaced?.Invoke(containerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retires the root and every nested container projection reachable through
|
||||
/// the last ViewContents snapshots. ACE sends a snapshot for each nested
|
||||
/// container; retail queues the external root's contents tree for destruction
|
||||
/// when the view closes.
|
||||
/// </summary>
|
||||
public int StopViewingContentsTree(uint rootContainerId)
|
||||
{
|
||||
if (rootContainerId == 0u)
|
||||
return 0;
|
||||
|
||||
var pending = new Stack<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
var removed = new List<uint>();
|
||||
pending.Push(rootContainerId);
|
||||
while (pending.Count != 0)
|
||||
{
|
||||
uint containerId = pending.Pop();
|
||||
if (!visited.Add(containerId)
|
||||
|| !_containerIndex.TryGetValue(containerId, out List<uint>? contents))
|
||||
continue;
|
||||
|
||||
foreach (uint childId in contents)
|
||||
{
|
||||
if (_containerIndex.ContainsKey(childId))
|
||||
pending.Push(childId);
|
||||
}
|
||||
_containerIndex.Remove(containerId);
|
||||
removed.Add(containerId);
|
||||
}
|
||||
|
||||
foreach (uint containerId in removed)
|
||||
ContainerContentsReplaced?.Invoke(containerId);
|
||||
return removed.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize canonical inventory ownership from PlayerDescription. This
|
||||
/// login manifest is object-state initialization, not a move transition.
|
||||
|
|
|
|||
98
src/AcDream.Core/Items/ExternalContainerState.cs
Normal file
98
src/AcDream.Core/Items/ExternalContainerState.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
public enum ExternalContainerTransitionKind
|
||||
{
|
||||
Opened,
|
||||
Replaced,
|
||||
Closed,
|
||||
Reset,
|
||||
}
|
||||
|
||||
public readonly record struct ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind Kind,
|
||||
uint PreviousContainerId,
|
||||
uint ContainerId);
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail <c>ClientUISystem::groundObject</c> and the outstanding external
|
||||
/// container request. ACE sends ViewContents for the requested root and then for
|
||||
/// nested containers; only the expected root may become the visible ground object.
|
||||
/// Retail: <c>ClientUISystem::OnViewContents @ 0x00565870</c> and
|
||||
/// <c>SetGroundObject @ 0x00564510</c>.
|
||||
/// </summary>
|
||||
public sealed class ExternalContainerState
|
||||
{
|
||||
public uint RequestedContainerId { get; private set; }
|
||||
public uint CurrentContainerId { get; private set; }
|
||||
|
||||
public event Action<ExternalContainerTransition>? Changed;
|
||||
|
||||
public bool RequestOpen(uint containerId)
|
||||
{
|
||||
if (containerId == 0u || RequestedContainerId == containerId)
|
||||
return false;
|
||||
RequestedContainerId = containerId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ApplyViewContents(uint containerId)
|
||||
{
|
||||
if (containerId == 0u || containerId != RequestedContainerId)
|
||||
return false;
|
||||
|
||||
uint previous = CurrentContainerId;
|
||||
CurrentContainerId = containerId;
|
||||
RequestedContainerId = containerId;
|
||||
if (previous == containerId)
|
||||
return false;
|
||||
|
||||
Changed?.Invoke(new ExternalContainerTransition(
|
||||
previous == 0u
|
||||
? ExternalContainerTransitionKind.Opened
|
||||
: ExternalContainerTransitionKind.Replaced,
|
||||
previous,
|
||||
containerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ApplyClose(uint containerId)
|
||||
{
|
||||
if (containerId == 0u || containerId != CurrentContainerId)
|
||||
return false;
|
||||
|
||||
uint previous = CurrentContainerId;
|
||||
CurrentContainerId = 0u;
|
||||
RequestedContainerId = 0u;
|
||||
Changed?.Invoke(new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Closed,
|
||||
previous,
|
||||
0u));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ApplyUseDone(uint weenieError)
|
||||
{
|
||||
if (weenieError == 0u || RequestedContainerId == CurrentContainerId)
|
||||
return false;
|
||||
RequestedContainerId = CurrentContainerId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Reset()
|
||||
{
|
||||
uint previous = CurrentContainerId;
|
||||
bool changed = previous != 0u || RequestedContainerId != 0u;
|
||||
CurrentContainerId = 0u;
|
||||
RequestedContainerId = 0u;
|
||||
if (changed)
|
||||
{
|
||||
Changed?.Invoke(new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Reset,
|
||||
previous,
|
||||
0u));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ public enum SelectionChangeSource
|
|||
World,
|
||||
Radar,
|
||||
Inventory,
|
||||
ExternalContainer,
|
||||
Paperdoll,
|
||||
Toolbar,
|
||||
Keyboard,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public sealed class ItemInteractionControllerTests
|
|||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
|
||||
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
|
||||
public readonly List<uint> ExternalRequests = new();
|
||||
public readonly List<uint> Drops = new();
|
||||
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
|
||||
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
|
||||
|
|
@ -28,6 +30,7 @@ public sealed class ItemInteractionControllerTests
|
|||
public uint SelectedObject;
|
||||
public bool NonCombatMode;
|
||||
public bool DragOnPlayerOpensSecureTrade = true;
|
||||
public uint GroundObject;
|
||||
public long Now = 1_000;
|
||||
|
||||
public Harness()
|
||||
|
|
@ -61,11 +64,19 @@ public sealed class ItemInteractionControllerTests
|
|||
selectedObjectId: () => SelectedObject,
|
||||
stackSplitQuantity: SplitQuantity,
|
||||
inNonCombatMode: () => NonCombatMode,
|
||||
groundObjectId: () => GroundObject,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
Puts.Add((item, container, placement)),
|
||||
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
|
||||
dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade,
|
||||
systemMessage: SystemMessages.Add);
|
||||
systemMessage: SystemMessages.Add,
|
||||
sendSplitToContainer: (item, container, placement, amount) =>
|
||||
SplitPuts.Add((item, container, placement, amount)),
|
||||
requestExternalContainer: id =>
|
||||
{
|
||||
GroundObject = id;
|
||||
ExternalRequests.Add(id);
|
||||
});
|
||||
}
|
||||
|
||||
public ItemInteractionController Controller { get; }
|
||||
|
|
@ -103,6 +114,51 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Empty(h.UseWithTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalContainerUse_RequestsGroundObjectAndSendsUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint chest = 0x70000001u;
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = chest,
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
Useability = ItemUseability.Remote,
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(chest));
|
||||
|
||||
Assert.Equal(new uint[] { chest }, h.Uses);
|
||||
Assert.Equal(new uint[] { chest }, h.ExternalRequests);
|
||||
Assert.Equal(chest, h.GroundObject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint itemId = 0x50000A33u;
|
||||
const uint chest = 0x70000001u;
|
||||
ClientObject item = h.AddContained(itemId);
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = chest,
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Openable,
|
||||
});
|
||||
h.GroundObject = chest;
|
||||
var cell = new UiItemSlot();
|
||||
cell.SetItem(itemId, 0u);
|
||||
var payload = new ItemDragPayload(itemId, ItemDragSource.Inventory, 0, cell);
|
||||
|
||||
Assert.True(h.Controller.PlaceIn3D(payload, chest));
|
||||
|
||||
Assert.Equal(new[] { (itemId, chest, 0) }, h.Puts);
|
||||
Assert.Equal(Pack, item.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrimaryClickRouter_distinguishesInactiveSuccessAndConsumedRejection()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class ExternalContainerControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Chest = 0x70000001u;
|
||||
private const uint Item = 0x60000001u;
|
||||
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
public readonly ExternalContainerState State = new();
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly SelectionState Selection = new();
|
||||
public readonly StackSplitQuantityState Split = new();
|
||||
public readonly List<uint> Uses = new();
|
||||
public readonly List<uint> NoLonger = new();
|
||||
public readonly List<uint> Pickups = new();
|
||||
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
|
||||
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> Splits = new();
|
||||
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
|
||||
public readonly UiItemList Top = new() { Width = 36f, Height = 36f };
|
||||
public readonly UiItemList Containers = new() { Width = 680f, Height = 36f };
|
||||
public readonly UiItemList Contents = new() { Width = 784f, Height = 32f };
|
||||
public readonly RetailWindowHandle Window;
|
||||
public readonly ItemInteractionController Interaction;
|
||||
public readonly ExternalContainerLifecycleController Lifecycle;
|
||||
public readonly ExternalContainerController Controller;
|
||||
public bool InRange = true;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var root = new TestElement { Width = 800f, Height = 110f };
|
||||
var close = new UiButton(
|
||||
new ElementInfo { Id = ExternalContainerController.CloseButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
var scrollbar = new UiScrollbar { Width = 784f, Height = 16f };
|
||||
root.AddChild(Top);
|
||||
root.AddChild(Containers);
|
||||
root.AddChild(Contents);
|
||||
root.AddChild(close);
|
||||
root.AddChild(scrollbar);
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[ExternalContainerController.TopContainerId] = Top,
|
||||
[ExternalContainerController.ContainerListId] = Containers,
|
||||
[ExternalContainerController.ContentsListId] = Contents,
|
||||
[ExternalContainerController.ContentsScrollbarId] = scrollbar,
|
||||
[ExternalContainerController.CloseButtonId] = close,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
Screen,
|
||||
root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.ExternalContainer,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
});
|
||||
|
||||
Interaction = new ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: Uses.Add,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => State.CurrentContainerId,
|
||||
placeInBackpack: Pickups.Add,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
Puts.Add((item, container, placement)),
|
||||
sendSplitToContainer: (item, container, placement, amount) =>
|
||||
Splits.Add((item, container, placement, amount)),
|
||||
requestExternalContainer: id => State.RequestOpen(id));
|
||||
Lifecycle = new ExternalContainerLifecycleController(State, Objects, NoLonger.Add);
|
||||
Controller = ExternalContainerController.Bind(
|
||||
layout,
|
||||
State,
|
||||
Objects,
|
||||
Selection,
|
||||
Interaction,
|
||||
Split,
|
||||
static (_, _, _, _, _) => 0u,
|
||||
static (_, _, _, _, _) => 0u,
|
||||
Uses.Add,
|
||||
(item, container, placement) => Puts.Add((item, container, placement)),
|
||||
(item, container, placement, amount) =>
|
||||
Splits.Add((item, container, placement, amount)),
|
||||
_ => InRange,
|
||||
Window);
|
||||
Screen.WindowManager.AttachController(WindowNames.ExternalContainer, Controller);
|
||||
}
|
||||
|
||||
public void Open(uint id = Chest, params ContainerContentEntry[] contents)
|
||||
{
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = id,
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
Useability = ItemUseability.Remote,
|
||||
});
|
||||
State.RequestOpen(id);
|
||||
Objects.ReplaceContents(id, contents);
|
||||
State.ApplyViewContents(id);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Lifecycle.Dispose();
|
||||
Interaction.Dispose();
|
||||
Screen.WindowManager.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeView_ShowsAuthoredStripAndPopulatesLists()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open(Chest, new ContainerContentEntry(Item, 0u));
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.Equal(Chest, h.Top.GetItem(0)!.ItemId);
|
||||
Assert.Equal(Item, h.Contents.GetItem(0)!.ItemId);
|
||||
Assert.True(h.Contents.HorizontalScroll);
|
||||
Assert.Same(h.Contents.Scroll,
|
||||
Assert.IsType<UiScrollbar>(h.Window.ContentRoot.Children.Last()).Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClickLoot_UsesSharedItemPolicyAndPicksUpItem()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open(Chest, new ContainerContentEntry(Item, 0u));
|
||||
|
||||
h.Contents.GetItem(0)!.DoubleClicked!();
|
||||
|
||||
Assert.Equal(new uint[] { Item }, h.Pickups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAndRangeExit_SendUseOnceWithoutNoLongerViewing()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open();
|
||||
h.InRange = false;
|
||||
|
||||
h.Controller.Tick();
|
||||
h.Controller.Tick();
|
||||
|
||||
Assert.Equal(new uint[] { Chest }, h.Uses);
|
||||
Assert.Empty(h.NoLonger);
|
||||
Assert.False(h.Window.IsVisible);
|
||||
|
||||
h.State.ApplyClose(Chest);
|
||||
Assert.Empty(h.NoLonger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replacement_SendsNoLongerViewingForPreviousRootExactlyOnce()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open(Chest);
|
||||
h.Open(0x70000002u);
|
||||
|
||||
Assert.Equal(new uint[] { Chest }, h.NoLonger);
|
||||
Assert.Equal(0x70000002u, h.State.CurrentContainerId);
|
||||
Assert.True(h.Window.IsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropOwnedPartialStackIntoChest_UsesSplitRequestWithoutLocalMove()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open();
|
||||
var item = new ClientObject { ObjectId = Item, StackSize = 5, Type = ItemType.Misc };
|
||||
h.Objects.AddOrUpdate(item);
|
||||
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);
|
||||
var payload = new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source);
|
||||
UiItemSlot target = h.Contents.GetItem(0)!;
|
||||
|
||||
h.Controller.HandleDropRelease(h.Contents, target, payload);
|
||||
|
||||
Assert.Equal(new[] { (Item, Chest, 0u, 2u) }, h.Splits);
|
||||
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
|
||||
Assert.Empty(h.Puts);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ public class InventoryControllerTests
|
|||
}
|
||||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
int? strength = 100, List<uint>? uses = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
List<(uint item, uint container, uint placement, uint amount)>? splits = null,
|
||||
List<(uint source, uint target, uint amount)>? merges = null,
|
||||
|
|
@ -68,7 +68,6 @@ public class InventoryControllerTests
|
|||
strength: () => strength, datFont: null,
|
||||
ownerName: ownerName is null ? null : () => ownerName,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||
sendStackableSplitToContainer: splits is null
|
||||
? null
|
||||
|
|
@ -410,7 +409,7 @@ public class InventoryControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
|
||||
public void ClickMainPackCell_afterBag_returnsToMainPack_withoutExternalCloseWire()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
@ -418,34 +417,30 @@ public class InventoryControllerTests
|
|||
SeedBag(objects, 0xC, slot: 1);
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
|
||||
var ctrl = Bind(layout, objects, uses: uses);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open the bag
|
||||
top.GetItem(0)!.Clicked!(); // click the main-pack cell
|
||||
|
||||
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
|
||||
Assert.DoesNotContain(Player, uses); // no Use for the main pack
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
|
||||
Assert.Equal(102, grid.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
|
||||
public void SwitchBetweenTwoBags_opensEach_withoutExternalCloseWire()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC1, slot: 0);
|
||||
SeedBag(objects, 0xC2, slot: 1);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
Bind(layout, objects, uses: uses);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
|
||||
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
|
||||
|
||||
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
|
||||
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -469,15 +464,13 @@ public class InventoryControllerTests
|
|||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
Bind(layout, objects, uses: uses);
|
||||
|
||||
grid.GetItem(0)!.Clicked!(); // select the loose item
|
||||
|
||||
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
|
||||
Assert.Empty(uses); // selection sends no wire
|
||||
Assert.Empty(closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -114,4 +114,36 @@ public class UiItemListTests
|
|||
list.ScrollItemIntoView(0);
|
||||
Assert.Equal(0, list.Scroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalScroll_UsesPixelOffsetAndKeepsPartialEdgeCells()
|
||||
{
|
||||
var list = new UiItemList
|
||||
{
|
||||
Width = 80f,
|
||||
Height = 32f,
|
||||
CellWidth = 32f,
|
||||
CellHeight = 32f,
|
||||
SingleRow = true,
|
||||
HorizontalScroll = true,
|
||||
};
|
||||
list.Flush();
|
||||
for (int i = 0; i < 4; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
list.Scroll.SetScrollY(17);
|
||||
list.LayoutCells();
|
||||
|
||||
Assert.Equal(128, list.Scroll.ContentHeight);
|
||||
Assert.Equal(80, list.Scroll.ViewHeight);
|
||||
Assert.Equal(-17f, list.GetItem(0)!.Left);
|
||||
Assert.True(list.GetItem(0)!.Visible);
|
||||
Assert.True(list.GetItem(3)!.Visible);
|
||||
Assert.False(list.GetItem(2) is null);
|
||||
|
||||
list.ScrollItemIntoView(3);
|
||||
Assert.Equal(48, list.Scroll.ScrollY);
|
||||
list.LayoutCells();
|
||||
Assert.Equal(48f, list.GetItem(3)!.Left);
|
||||
Assert.Equal(80f, list.GetItem(3)!.Left + list.GetItem(3)!.Width);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,31 @@ public class UiScrollbarTests
|
|||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalModel_ButtonsTrackAndThumbDriveSharedScroll()
|
||||
{
|
||||
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
|
||||
var bar = new UiScrollbar
|
||||
{
|
||||
Width = 160f,
|
||||
Height = 16f,
|
||||
Horizontal = true,
|
||||
Model = model,
|
||||
};
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 159)));
|
||||
Assert.Equal(32, model.ScrollY);
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 100)));
|
||||
Assert.True(model.ScrollY >= 80);
|
||||
|
||||
model.SetScrollY(0);
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
|
||||
Assert.Equal(model.MaxScroll, model.ScrollY);
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 0f, 0f)]
|
||||
[InlineData(0.5f, 0f, 50f)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class ExternalContainerLifecycleControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Replacement_RetiresPreviousRootExactlyOnce()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
var objects = new ClientObjectTable();
|
||||
var retired = new List<uint>();
|
||||
using var controller = new ExternalContainerLifecycleController(state, objects, retired.Add);
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 100u });
|
||||
objects.ReplaceContents(10u, new[] { new ContainerContentEntry(100u, 0u) });
|
||||
|
||||
state.RequestOpen(10u);
|
||||
state.ApplyViewContents(10u);
|
||||
state.RequestOpen(20u);
|
||||
state.ApplyViewContents(20u);
|
||||
state.ApplyViewContents(20u);
|
||||
|
||||
Assert.Equal(new uint[] { 10u }, retired);
|
||||
Assert.Empty(objects.GetContents(10u));
|
||||
Assert.NotNull(objects.Get(100u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredCloseAndReset_DoNotSendNoLongerViewing()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
var objects = new ClientObjectTable();
|
||||
var retired = new List<uint>();
|
||||
using var controller = new ExternalContainerLifecycleController(state, objects, retired.Add);
|
||||
|
||||
state.RequestOpen(10u);
|
||||
state.ApplyViewContents(10u);
|
||||
objects.ReplaceContents(10u, new[] { new ContainerContentEntry(100u, 0u) });
|
||||
state.ApplyClose(10u);
|
||||
Assert.Empty(objects.GetContents(10u));
|
||||
state.RequestOpen(20u);
|
||||
state.ApplyViewContents(20u);
|
||||
objects.ReplaceContents(20u, new[] { new ContainerContentEntry(200u, 0u) });
|
||||
state.Reset();
|
||||
|
||||
Assert.Empty(retired);
|
||||
Assert.Empty(objects.GetContents(20u));
|
||||
}
|
||||
}
|
||||
|
|
@ -950,6 +950,45 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ExternalContainer_FiltersNestedViewsAndAppliesAuthoritativeClose()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var items = new ClientObjectTable();
|
||||
var external = new ExternalContainerState();
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
items,
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
externalContainers: external);
|
||||
external.RequestOpen(0x70000001u);
|
||||
|
||||
DispatchViewContents(
|
||||
dispatcher,
|
||||
0x70000001u,
|
||||
new uint[] { 0x60000001u, 0x70000002u });
|
||||
DispatchViewContents(dispatcher, 0x70000002u, new uint[] { 0x60000002u });
|
||||
|
||||
Assert.Equal(0x70000001u, external.CurrentContainerId);
|
||||
Assert.Equal(
|
||||
new uint[] { 0x60000001u, 0x70000002u },
|
||||
items.GetContents(0x70000001u));
|
||||
Assert.Equal(new uint[] { 0x60000002u }, items.GetContents(0x70000002u));
|
||||
|
||||
byte[] closePayload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(closePayload, 0x70000001u);
|
||||
var close = GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.CloseGroundContainer, closePayload));
|
||||
dispatcher.Dispatch(close!.Value);
|
||||
|
||||
Assert.Equal(0u, external.CurrentContainerId);
|
||||
Assert.Empty(items.GetContents(0x70000001u));
|
||||
Assert.NotNull(items.Get(0x60000001u));
|
||||
Assert.Empty(items.GetContents(0x70000002u));
|
||||
}
|
||||
|
||||
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
|
||||
{
|
||||
byte[] payload = new byte[8 + itemGuids.Length * 8];
|
||||
|
|
|
|||
|
|
@ -46,6 +46,41 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(1, propUpdateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopViewingContents_RemovesOnlyTemporaryProjection()
|
||||
{
|
||||
var repo = new ClientObjectTable();
|
||||
ClientObject item = MakeItem(100u);
|
||||
item.ContainerId = 77u;
|
||||
repo.AddOrUpdate(item);
|
||||
repo.ReplaceContents(77u, new uint[] { 100u });
|
||||
uint? changed = null;
|
||||
repo.ContainerContentsReplaced += id => changed = id;
|
||||
|
||||
Assert.True(repo.StopViewingContents(77u));
|
||||
|
||||
Assert.Empty(repo.GetContents(77u));
|
||||
Assert.Same(item, repo.Get(100u));
|
||||
Assert.Equal(77u, item.ContainerId);
|
||||
Assert.Equal(77u, changed);
|
||||
Assert.False(repo.StopViewingContents(77u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopViewingContentsTree_RemovesNestedSnapshotsButPreservesObjects()
|
||||
{
|
||||
var repo = new ClientObjectTable();
|
||||
repo.ReplaceContents(77u, new[] { new ContainerContentEntry(88u, 1u) });
|
||||
repo.ReplaceContents(88u, new[] { new ContainerContentEntry(100u, 0u) });
|
||||
|
||||
Assert.Equal(2, repo.StopViewingContentsTree(77u));
|
||||
|
||||
Assert.Empty(repo.GetContents(77u));
|
||||
Assert.Empty(repo.GetContents(88u));
|
||||
Assert.NotNull(repo.Get(88u));
|
||||
Assert.NotNull(repo.Get(100u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveItem_UpdatesContainerAndFiresEvent()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class ExternalContainerStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ViewContents_onlyCommitsTheExpectedRoot()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
state.RequestOpen(0x70000001u);
|
||||
|
||||
Assert.False(state.ApplyViewContents(0x70000002u));
|
||||
Assert.Equal(0u, state.CurrentContainerId);
|
||||
Assert.True(state.ApplyViewContents(0x70000001u));
|
||||
Assert.Equal(0x70000001u, state.CurrentContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacementPublishesOldAndNewExactlyOnce()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
var changes = new List<ExternalContainerTransition>();
|
||||
state.Changed += changes.Add;
|
||||
state.RequestOpen(1u);
|
||||
state.ApplyViewContents(1u);
|
||||
state.RequestOpen(2u);
|
||||
state.ApplyViewContents(2u);
|
||||
state.ApplyViewContents(2u);
|
||||
|
||||
Assert.Equal(2, changes.Count);
|
||||
Assert.Equal(ExternalContainerTransitionKind.Opened, changes[0].Kind);
|
||||
Assert.Equal(
|
||||
new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Replaced, 1u, 2u),
|
||||
changes[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseIgnoresStaleContainerAndClosesCurrent()
|
||||
{
|
||||
var state = Open(2u);
|
||||
|
||||
Assert.False(state.ApplyClose(1u));
|
||||
Assert.Equal(2u, state.CurrentContainerId);
|
||||
Assert.True(state.ApplyClose(2u));
|
||||
Assert.Equal(0u, state.CurrentContainerId);
|
||||
Assert.Equal(0u, state.RequestedContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusedReplacementRestoresCurrentAsExpectedRoot()
|
||||
{
|
||||
var state = Open(1u);
|
||||
state.RequestOpen(2u);
|
||||
|
||||
Assert.True(state.ApplyUseDone(0x550u));
|
||||
Assert.Equal(1u, state.CurrentContainerId);
|
||||
Assert.Equal(1u, state.RequestedContainerId);
|
||||
Assert.False(state.ApplyViewContents(2u));
|
||||
}
|
||||
|
||||
private static ExternalContainerState Open(uint id)
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
state.RequestOpen(id);
|
||||
state.ApplyViewContents(id);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue