From 97cf873870ccf95b1e4c8b83724ef982133e816b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 20:28:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(vendor):=20Slice=206=20buy=20arc=20?= =?UTF-8?q?=E2=80=94=20shop=20items=20are=20real=20objects,=20vendor=20sel?= =?UTF-8?q?ection=20is=20THE=20selection,=20and=20Buy=20works=20(0x005F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ordered pieces in one landing (the shared controller/composition files carry all three; the internal order was 6.1 -> 6.2 -> 6.3): 6.1 VendorShopItemMaterializer diff-merges the shop list into the live ClientObjectTable on VendorState transitions (so client-local close and session teardown retire the entries too) and never claims a guid it did not add — ACE's UniqueItemsForSale can re-list a guid a player once held (AP-163 files the collision-skip; no retail counterpart traced). Right-click examine on shop items now routes through the ordinary appraisal path — the 5.4 F7c blocker dissolves with the table entries. 6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine all flow through the canonical SelectionState; the status bar and the existing byte-faithful StackSplitQuantityState slider light up unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the stack (the splitSize/maxSplitSize distinction, research §B.3). Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop (pc:202848), not a CloseVendor-level clear that does not exist. 6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs, and the trailing alternateCurrencyId the REAL client sends (CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it. TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and completes on UseDone; the Buy button disables while a request is in flight. The reconciliation round-trip (money property update, inventory CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a synthetic-inbound test against existing machinery — no new owner. Register: AP-161 narrowed (selection + examine residuals close; staging/Sell remain; double-click-to-buy confirmed ABSENT from retail with negative evidence cited — we match retail). AP-162 files the conscious no-client-side-affordability-precheck deferral. Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 --- .../retail-divergence-register.md | 6 +- .../InteractionRetainedUiComposition.cs | 31 +- .../UI/ItemInteractionController.cs | 43 ++- .../UI/Layout/SelectedObjectController.cs | 39 ++- .../UI/Layout/VendorUiController.cs | 296 +++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 22 +- .../Messages/VendorRequests.cs | 110 +++++++ src/AcDream.Core.Net/WorldSession.cs | 13 + src/AcDream.Core/Items/VendorSplitPolicy.cs | 45 +++ src/AcDream.Core/Selection/SelectionState.cs | 5 + .../Gameplay/RuntimeInventoryState.cs | 28 +- .../Gameplay/VendorShopItemMaterializer.cs | 202 ++++++++++++ .../UI/ItemInteractionControllerTests.cs | 124 +++++++- .../Layout/SelectedObjectControllerTests.cs | 6 +- .../UI/Layout/VendorUiControllerTests.cs | 240 +++++++++++++- .../Messages/VendorRequestsTests.cs | 127 ++++++++ .../WorldSessionVendorTests.cs | 64 ++++ .../Gameplay/RuntimeVendorLifecycleTests.cs | 50 +++ .../VendorShopItemMaterializerTests.cs | 191 +++++++++++ 19 files changed, 1577 insertions(+), 65 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/VendorRequests.cs create mode 100644 src/AcDream.Core/Items/VendorSplitPolicy.cs create mode 100644 src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 12869862..6c497ed9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 112 active rows (AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 114 active rows (AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -288,7 +288,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand 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). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `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` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | -| AP-161 | **Filed 2026-08-09, Slice 5.4 (vendor browse panel); REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layers, stale-category, and unguarded-fanout bugs the review found are all FIXED; this row now covers exactly what CONSCIOUSLY remains.** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy`/`Add to List` buttons (`0x100000C2`/`C3`) now correctly enable/disable with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) but clicking them (once Slice 6 wires an action) remains unimplemented. No buy/sell wire message exists anywhere in this slice; `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread. Four divergences the F1-F8 fix pass consciously LEFT: (1) item selection is a PRIVATE per-panel `_selectedItemGuid`, not retail's global `ACCWeenieObject::selectedID`/`GenItemHolder::splitSize` pair — a toolbar Examine/Use on the same guid, or another panel's selection, neither reads nor drives this panel's highlight; (2) right-click examine on a shop item is NOT wired (F7c, STOPped rather than faked) — `AppraisalUiController.Apply` requires a live `ClientObjectTable` entry (`_objects.Get(appraisal.Guid)`, `src/AcDream.App/UI/Layout/AppraisalUiController.cs:418-420`) and vendor items are never registered there (`GameEventWiring.cs`'s `ApproachVendor` handler only calls `vendor?.Apply(...)`, never touches `items`/`ClientObjectTable`); faking the wiring would silently swallow every appraisal response instead of surfacing the gap; (3) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (4) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until Slice 6 adds a purchase path, since `m_last_sale` only changes on a completed buy (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`). | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter is browse only. The global `ACCWeenieObject::selectedID` coupling and shop-item `ClientObjectTable` registration are the SAME Slice 6/global-selection-seam work already fenced by the original filing; the F1-F8 review fix did not expand that fence, it only corrected the popup/pricing/selection/icon DISPLAY bugs living inside the existing private-selection model. | A player can look at prices and select items but cannot buy or sell anything; clicking "Buying"/"Selling" shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. Right-clicking a shop item to examine it does nothing at all (not even a request sent) — Slice 6 must add the `ClientObjectTable` registration before wiring examine, not aim it at an unrelated player-inventory or world-entity guid. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | +| AP-161 | **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy`/`Add to List` buttons (`0x100000C2`/`C3`) correctly enable/disable with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`); Slice 6.3 wires `Buy` to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`), while `Add to List` (staging) remains unimplemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | +| AP-162 | **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | +| AP-163 | **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index ec860bea..71fc2295 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -361,7 +361,16 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory { d.Inventory.ExternalContainers.RequestOpen(guid); }, - requestUse: selection.RequestUse); + requestUse: selection.RequestUse, + // Slice 6.3: ItemInteractionController.TryBuy owns the + // reservation dance itself (see its doc comment); this is a + // plain wire send, not a second requestUse-shaped delegate. + sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) => + session.CurrentSession?.SendBuy( + vendorGuid, + itemGuid, + amount, + alternateCurrencyId)); } public MagicRuntime CreateMagicRuntime( @@ -655,7 +664,21 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.Session.CurrentSession?.SendPutItemInContainer( item, container, - placement)), + placement), + // Slice 6.2: retail gmToolbarUI::HandleSelectionChanged's + // own vendor-owned gate (pc:198781) is literally + // "selected weenie's ContainerId == the open vendor's + // guid" — Slice 6.1 guarantees a materialized shop + // item's ContainerId IS the vendor's guid, so this reads + // straight off the same ClientObjectTable/VendorState + // pair VendorUiController.VendorSplitSize's display-only + // copy also reads, through the SAME VendorSplitPolicy + // mask helper (no second mask copy). + guid => + d.Inventory.Vendor.VendorId != 0u + && d.Inventory.Objects.Get(guid) is { } vendorCandidate + && vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId + && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)), Character: new CharacterRuntimeBindings(characterSheet), Inventory: new InventoryRuntimeBindings( d.Inventory.Objects, @@ -706,7 +729,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.Selection.IsWithinExternalContainerUseRange), Vendor: new VendorRuntimeBindings( d.Inventory.Vendor, - iconComposer.GetIcon), + iconComposer.GetIcon, + itemInteraction, + d.Actions.Selection), Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), Confirmations: new ConfirmationRuntimeBindings( (type, context, accepted) => diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index db968d08..93a78569 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -61,6 +61,8 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _systemMessage; private readonly AutoWieldController _autoWield; private readonly Action? _requestUse; + // Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId. + private readonly Action? _sendBuy; private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; @@ -101,7 +103,8 @@ public sealed class ItemInteractionController : IDisposable Action? requestExternalContainer = null, CombatState? combatState = null, Action? sendChangeCombatMode = null, - Action? requestUse = null) + Action? requestUse = null, + Action? sendBuy = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -131,6 +134,7 @@ public sealed class ItemInteractionController : IDisposable _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); _systemMessage = systemMessage; _requestUse = requestUse; + _sendBuy = sendBuy; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); _runtimeTransactions = runtimeTransactions @@ -222,6 +226,43 @@ public sealed class ItemInteractionController : IDisposable return false; } + /// + /// Slice 6.3: retail's Buy button — gmVendorUI::BuySingleItem + /// (pc:201661, 0x004C2820). Immediate single-item purchase + /// of (a vendor shop item) at + /// units, with the vendor's own trade currency + /// (0 = pyreal). Rides the EXISTING one-request-at-a-time reservation + /// ordinary Use takes (research doc §A.4: every + /// HandleActionBuyItem path ends in exactly one + /// SendUseDoneEvent(), the SAME completion signal + /// RuntimeInteractionTransactionState.CompleteUse already + /// resolves via the wired UseDone (0x01C7) handler) — no second + /// gate, and retail's client-side busy-count increment + /// (ClientUISystem::IncrementBusyCount, pc:201765) is + /// exactly what 's + /// BusyCount == 0 check already guards for every other request. + /// + public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId) + { + if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null) + return false; + if (!EnsureInventoryRequestReady()) + return false; + + ItemUseRequestReservation reservation = BeginUseRequestReservation(); + try + { + _sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId); + } + catch + { + reservation.CancelBeforeDispatch(); + throw; + } + reservation.MarkDispatched(); + return true; + } + /// /// Retail UIElement_ItemList::AcceptDragObject's local /// m_pendingItem branch. This wording belongs only to the destination diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 224f9de8..b101a5dc 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -93,6 +93,7 @@ public sealed class SelectedObjectController : IRetainedPanelController private readonly Action _sendQueryItemMana; private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; + private readonly Func _isVendorSplitExempt; private readonly Action> _unsubscribeHealthChanged; private readonly Action> _unsubscribeItemManaChanged; private readonly Action> _unsubscribeObjectUpdated; @@ -126,7 +127,8 @@ public sealed class SelectedObjectController : IRetainedPanelController UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, - Action> unsubscribeObjectUpdated) + Action> unsubscribeObjectUpdated, + Func isVendorSplitExempt) { _isHealthTarget = isHealthTarget; _isOwnedByPlayer = isOwnedByPlayer; @@ -139,6 +141,8 @@ public sealed class SelectedObjectController : IRetainedPanelController _sendQueryItemMana = sendQueryItemMana; _splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _isVendorSplitExempt = isVendorSplitExempt + ?? throw new ArgumentNullException(nameof(isVendorSplitExempt)); _unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeObjectUpdated = unsubscribeObjectUpdated; @@ -250,6 +254,18 @@ public sealed class SelectedObjectController : IRetainedPanelController /// Returns the stack size for a guid (0 or 1 = non-stacked). /// Sends retail QueryHealth (0x01BF); may be a no-op offline. /// Dat font for the name label; null = debug bitmap font fallback. + /// + /// Slice 6.2: retail's gmToolbarUI::HandleSelectionChanged vendor + /// branch (pc:198779-198790) — true when the selected guid is + /// owned by the currently-open vendor (its ClientObject.ContainerId + /// equals VendorState.VendorId) AND its type intersects + /// . When true, a stack + /// seeds to quantity 1 instead of the full authored stack size — see + /// . VendorUiController.VendorSplitSize + /// answers the SAME question for vendor's own display text via the SAME + /// helper, so the mask exists in exactly + /// one place (composed at InteractionRetainedUiComposition). + /// public static SelectedObjectController Bind( ImportedLayout layout, SelectionState selection, @@ -269,14 +285,16 @@ public sealed class SelectedObjectController : IRetainedPanelController UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, - Action> unsubscribeObjectUpdated) + Action> unsubscribeObjectUpdated, + Func isVendorSplitExempt) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, subscribeItemManaChanged, unsubscribeItemManaChanged, isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, sendQueryHealth, manaPercent, sendQueryItemMana, datFont, - splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated); + splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, + isVendorSplitExempt); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): @@ -335,11 +353,20 @@ public sealed class SelectedObjectController : IRetainedPanelController // gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666: // stacks initialize to the full stack, show the numeric entry + horizontal - // slider, and set the stacked selection state. Vendor-owned stack precedence - // is intentionally absent until the vendor panel owns an active vendor id. + // slider, and set the stacked selection state. Slice 6.2: the + // vendor-owned branch (pc:198779-198790, mask literal pc:198784) + // seeds splitSize (the INITIAL value) to 1 instead of the full stack + // when the selection is owned by the currently-open vendor AND its + // type intersects VendorSplitPolicy.SplitExemptMask — see the + // isVendorSplitExempt parameter doc. maxSplitSize (the slider's + // RANGE) is always the full authored stack size regardless of + // exemption (research doc §B.3: "Sets GenItemHolder::splitSize = + // seed, GenItemHolder::maxSplitSize = stackSize") — only the + // starting VALUE differs, not the ceiling. if (stackSize > 1u) { - _splitQuantity.Reset(stackSize); + uint seed = _isVendorSplitExempt(g) ? 1u : stackSize; + _splitQuantity.Reset(stackSize, initialValue: seed); if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; } diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 7a514d8a..2985cfca 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -5,6 +5,7 @@ using System.Linq; using AcDream.App.Rendering; using AcDream.Core.Items; using AcDream.Core.Properties; +using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; @@ -149,19 +150,6 @@ public sealed class VendorUiController : IRetainedPanelController private const uint TypeMenuNormalSprite = 0x060012B3u; private const uint TypeMenuPressedSprite = 0x060012B4u; - /// - /// F2 mask rule (Slice 5.4 review): the split-exempt ItemType - /// mask gmToolbarUI::HandleSelectionChanged applies when seeding - /// GenItemHolder::splitSize for a vendor-owned selection - /// (pc:198779-198790, literal mask at pc:198784). Every - /// row shows IS vendor-owned (its - /// container is unconditionally the open vendor), so the "does this - /// item belong to the open vendor" gate that precedes the mask check in - /// retail's function is always true here and is not reproduced - /// separately — see . - /// - private const uint SplitExemptMask = 0x0DC41CB0u; - /// /// Retail's ordered category table, transcribed verbatim from /// VendorItemsUI::OpenVendor's AddTypeFilter call chain @@ -197,6 +185,9 @@ public sealed class VendorUiController : IRetainedPanelController private readonly Func _resolveIcon; private readonly ClientObjectTable _objects; private readonly Func _playerGuid; + private readonly ItemInteractionController _itemInteraction; + private readonly SelectionState _selection; + private readonly StackSplitQuantityState _splitQuantity; private readonly UiElement _itemsPage; private readonly UiElement _buyingPage; private readonly UiElement _sellingPage; @@ -213,7 +204,11 @@ public sealed class VendorUiController : IRetainedPanelController private readonly List<(string Label, ItemType Mask)> _presentCategories = new(); private int _selectedCategoryIndex = -1; - private uint _selectedItemGuid; + // Slice 6.3: tracks "does the CURRENT selection permit Buy/Add" separately + // from "is a Buy request currently in flight" (RecomputeBuyButtonEnabled + // combines both for the Buy button specifically; Add is selection-only, + // per contract decision 6 — staging stays unwired this pass). + private bool _buyEnabledBySelection; private bool _disposed; private VendorUiController( @@ -222,6 +217,9 @@ public sealed class VendorUiController : IRetainedPanelController Func resolveIcon, ClientObjectTable objects, Func playerGuid, + ItemInteractionController itemInteraction, + SelectionState selection, + StackSplitQuantityState splitQuantity, UiElement itemsPage, UiElement buyingPage, UiElement sellingPage, @@ -246,6 +244,9 @@ public sealed class VendorUiController : IRetainedPanelController _resolveIcon = resolveIcon; _objects = objects; _playerGuid = playerGuid; + _itemInteraction = itemInteraction; + _selection = selection; + _splitQuantity = splitQuantity; _itemsPage = itemsPage; _buyingPage = buyingPage; _sellingPage = sellingPage; @@ -276,6 +277,13 @@ public sealed class VendorUiController : IRetainedPanelController { SpriteResolve = _itemList.SpriteResolve, }; + // Slice 6.1: mirrors ExternalContainerController's own + // right-click-examine wiring (UiItemSlot.OnEvent's RightClick case). + // Now that shop items are materialized into ClientObjectTable (see + // VendorShopItemMaterializer), AppraisalUiController.Apply's lookup + // succeeds and this stops being a dead end — closes half of AP-161 + // finding #2. + _itemList.ExamineItemRequested = ExamineItem; if (itemScrollbar is not null) { itemScrollbar.Model = _itemList.Scroll; @@ -310,11 +318,40 @@ public sealed class VendorUiController : IRetainedPanelController RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling)); if (_close is not null) _close.OnClick = () => _vendor.Close(); + // Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case — + // BuySingleItem(selectedID) — an immediate single-item purchase, no + // staging list required (research doc §B.1). + if (_buyButton is not null) + _buyButton.OnClick = BuySelectedItem; ShowTab(VendorPanelTab.Items); ClearContent(); _vendor.Changed += OnVendorChanged; + // Slice 6.2: SelectionState is now the AUTHORITY (research doc §B.4: + // vendor-context selections flow through the SAME global + // ACCWeenieObject::SetSelectedObject primitive as every other + // origin) — this panel is a CONSUMER, mirroring + // ExternalContainerController.OnSelectionChanged's shape exactly. + _selection.Changed += OnSelectionTransition; + // Slice 6.2: mirrors ExternalContainerController.OnObjectRemoved — + // retail's VendorItemsUI::RemoveFromShop (pc:202848-202850, + // 0x004c3d4a) clears the global selection when a shop item leaves + // the list (there is no separate CloseVendor-level SetSelectedObject(0) + // call; retail's ItemList_Flush on close does not touch selection + // directly). Since VendorShopItemMaterializer removes every + // materialized item from ClientObjectTable on Close/Reset/replace + // (Slice 6.1), subscribing here gives "vendor session close clears + // a vendor-owned selection" as a consequence of the SAME generic + // mechanism every other panel already uses, not a vendor-specific + // special case. + _objects.ObjectRemoved += OnObjectRemoved; + // Slice 6.3: mirrors ExternalContainerController's own + // _itemInteraction.StateChanged subscription — the Buy button must + // disable the instant a reservation is taken (BeginUseRequestReservation + // increments BusyCount synchronously, before the wire send), and + // re-enable on the matching UseDone/cancel, without polling. + _itemInteraction.StateChanged += OnInteractionStateChanged; } /// @@ -325,6 +362,28 @@ public sealed class VendorUiController : IRetainedPanelController /// ObjectTableWiring's PrivateUpdatePropertyInt routing. /// /// Resolves the local player's guid to look up in . + /// + /// Slice 6.1: the shared retail item interaction orchestrator — its + /// ExamineSelectedOrEnterMode is what right-click-examine on a + /// shop row routes through, mirroring + /// 's own examine wiring. + /// + /// + /// Slice 6.2: the canonical — now the + /// AUTHORITY for shop-row selection (row clicks, the F4 auto-select + /// fallback, and right-click examine all call + /// directly); this panel only + /// listens and reacts, the same way every sibling panel does. + /// + /// + /// Slice 6.3: the shared toolbar stack-quantity control (retail + /// GenItemHolder::splitSize/maxSplitSize) — the Buy button + /// reads the SAME live value + /// seeds/the player adjusts via ItemHolder::GetObjectSplitSize + /// (0x00586F00), matching retail's BuySingleItem + /// (pc:201674-201681: quantity 1 if _stackSize <= 1, + /// else the current slider value). + /// /// Retail dat font for the category dropdown's button/row labels. /// Fallback debug bitmap font (used when is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). @@ -336,6 +395,9 @@ public sealed class VendorUiController : IRetainedPanelController Func resolveIcon, ClientObjectTable objects, Func playerGuid, + ItemInteractionController itemInteraction, + SelectionState selection, + StackSplitQuantityState splitQuantity, UiDatFont? datFont, BitmapFont? debugFont, Func resolveSprite, @@ -347,6 +409,9 @@ public sealed class VendorUiController : IRetainedPanelController ArgumentNullException.ThrowIfNull(resolveIcon); ArgumentNullException.ThrowIfNull(objects); ArgumentNullException.ThrowIfNull(playerGuid); + ArgumentNullException.ThrowIfNull(itemInteraction); + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(splitQuantity); ArgumentNullException.ThrowIfNull(resolveSprite); if (layout.FindElement(ItemsPageId) is not { } itemsPage @@ -374,6 +439,9 @@ public sealed class VendorUiController : IRetainedPanelController resolveIcon, objects, playerGuid, + itemInteraction, + selection, + splitQuantity, itemsPage, buyingPage, sellingPage, @@ -517,11 +585,12 @@ public sealed class VendorUiController : IRetainedPanelController /// FIRST item that passed the filter becomes the display selection when /// the previous one didn't survive it, and the list unconditionally /// scrolls back to its start (ScrollToShow(m_shopList, 0)). - /// Retail routes the selection through the global - /// ACCWeenieObject::selectedID/SetSelectedObject; this - /// keeps the existing PRIVATE selection - /// instead of wiring that global seam (deferred — see the register, - /// AP-161). + /// Slice 6.2: retail routes the selection through the global + /// ACCWeenieObject::selectedID/SetSelectedObject + /// (pc:201184, confirmed to be the SAME primitive as the fallback + /// select here) — this now calls + /// instead of the retired private field, so the toolbar status bar and + /// slider light up for the auto-selected item too. /// private void RebuildItemList() { @@ -531,6 +600,7 @@ public sealed class VendorUiController : IRetainedPanelController uint maskValue = (uint)activeMask; IReadOnlyList items = _vendor.Items; + uint? selectedGuid = _selection.SelectedObjectId; bool selectionStillPresent = false; VendorShopItem? firstItem = null; @@ -544,7 +614,7 @@ public sealed class VendorUiController : IRetainedPanelController if (((item.ItemType ?? 0u) & maskValue) == 0u) continue; firstItem ??= item; - if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true; + if (item.ItemGuid == selectedGuid) selectionStillPresent = true; // F5 (Slice 5.4 review): forward the icon underlay/ // overlay/effects PublicWeenieDescParser already @@ -562,9 +632,9 @@ public sealed class VendorUiController : IRetainedPanelController SlotIndex = _itemList.GetNumUIItems(), }; cell.SetItem(item.ItemGuid, icon); - cell.Selected = item.ItemGuid == _selectedItemGuid; + cell.Selected = item.ItemGuid == selectedGuid; VendorShopItem captured = item; - cell.Clicked = () => SelectItem(captured); + cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); _itemList.AddItem(cell); } } @@ -572,8 +642,10 @@ public sealed class VendorUiController : IRetainedPanelController if (!selectionStillPresent) { - if (firstItem is { } first) SelectItem(first); - else ClearSelection(); + if (firstItem is { } first) + _selection.Select(first.ItemGuid, SelectionChangeSource.Vendor); + else + _selection.Clear(SelectionChangeSource.Vendor); } // F7a: unconditional scroll-to-start on every rebuild (retail only @@ -585,7 +657,13 @@ public sealed class VendorUiController : IRetainedPanelController /// /// Port of retail row selection display — /// VendorItemsUI::UpdateItemsUI (0x004C38E0, - /// pc:202539-202820). + /// pc:202539-202820). Slice 6.2: called ONLY from + /// , once has + /// already been confirmed to be the globally-selected guid — this method + /// no longer writes the selection itself ( + /// is the authority; row clicks, the F4 auto-select fallback, and + /// right-click examine all call + /// directly and let this method react). /// /// /// F2/F3 (Slice 5.4 review): the priced/named QUANTITY is retail's @@ -608,14 +686,12 @@ public sealed class VendorUiController : IRetainedPanelController /// retail state pair (see ToolbarController's /// _useButton.Enabled). /// - private void SelectItem(VendorShopItem item) + private void ApplyItemDisplay(VendorShopItem item) { - _selectedItemGuid = item.ItemGuid; - for (int i = 0; i < _itemList.GetNumUIItems(); i++) { if (_itemList.GetItem(i) is { } cell) - cell.Selected = cell.ItemId == _selectedItemGuid; + cell.Selected = cell.ItemId == item.ItemGuid; } int quantity = VendorSplitSize(item); @@ -635,20 +711,86 @@ public sealed class VendorUiController : IRetainedPanelController SetActionButtonsEnabled(true); } + /// + /// Right-click examine on a shop row — mirrors + /// ExternalContainerController.ExamineItem's "select then + /// request appraisal" shape, matching retail's single-selection model + /// (research doc §B.2: no dedicated double-click mechanism, plain + /// select-then-act). Slice 6.2: routes through + /// + /// applies the display update, so this method no longer needs to search + /// 's items itself. + /// + private void ExamineItem(uint guid) + { + _selection.Select(guid, SelectionChangeSource.Vendor); + _itemInteraction.ExamineSelectedOrEnterMode(guid); + } + + /// + /// Slice 6.2: reacts to ANY global selection change, not just ones this + /// panel originated — mirrors ExternalContainerController.OnSelectionChanged. + /// Shows this panel's own price/name text for the newly-selected guid + /// when it is one of 's current items; clears the + /// panel's display otherwise (a selection made in some OTHER panel while + /// the vendor window is open must not leave stale vendor pricing text + /// on screen). + /// + private void OnSelectionTransition(SelectionTransition transition) + { + _ = transition; + uint? selected = _selection.SelectedObjectId; + if (selected is { } guid) + { + foreach (VendorShopItem item in _vendor.Items) + { + if (item.ItemGuid == guid) + { + ApplyItemDisplay(item); + return; + } + } + } + ClearSelectionDisplay(); + } + + /// + /// Slice 6.2: retail's VendorItemsUI::RemoveFromShop + /// (pc:202848-202850, 0x004c3d4a) clears the global + /// selection when a shop item leaves the vendor's list — this mirrors + /// that (and, transitively, every other panel's own + /// OnObjectRemoved) rather than a vendor-specific "on close, set + /// selected to 0" special case. VendorShopItemMaterializer + /// removing every materialized item on session close/reset/replace + /// (Slice 6.1) is therefore what actually drives "vendor session close + /// clears a vendor-owned selection." + /// + private void OnObjectRemoved(ClientObject item) + { + if (_selection.SelectedObjectId == item.ObjectId) + { + _selection.Clear( + SelectionChangeSource.Vendor, + SelectionChangeReason.SelectedObjectRemoved); + } + } + /// /// The quantity retail prices/names a vendor-shop selection at — /// gmToolbarUI::HandleSelectionChanged's vendor-owned branch - /// (pc:198779-198790, mask literal at pc:198784): 1 unit - /// if the item's type intersects , else its - /// own authored stack size (0/absent treated as 1, matching - /// ItemHolder::GetObjectSplitSize's own stackSize==0 -> 1 - /// floor, pc:401473-401476). + /// (pc:198779-198790). Every row + /// shows IS vendor-owned (its container is unconditionally the open + /// vendor), so the "does this item belong to the open vendor" gate that + /// precedes the mask check in retail's function is always true here and + /// is not reproduced separately. Slice 6.2: delegates to + /// — the single source of truth for the + /// 0xDC41CB0 mask, also used by SelectedObjectController's + /// REAL seeding path (InteractionRetainedUiComposition's + /// isVendorSplitExempt delegate) so the mask exists in exactly + /// one place. /// - private static int VendorSplitSize(VendorShopItem item) - { - if (((item.ItemType ?? 0u) & SplitExemptMask) != 0u) return 1; - return item.DescStackSize is { } size && size > 0 ? size : 1; - } + private static int VendorSplitSize(VendorShopItem item) => + VendorSplitPolicy.SeedQuantity((ItemType)(item.ItemType ?? 0u), item.DescStackSize); /// /// Cost sentence — VendorItemsUI::UpdateItemsUI's tail @@ -698,9 +840,8 @@ public sealed class VendorUiController : IRetainedPanelController playerTotal.ToString("N0", CultureInfo.InvariantCulture)); } - private void ClearSelection() + private void ClearSelectionDisplay() { - _selectedItemGuid = 0u; for (int i = 0; i < _itemList.GetNumUIItems(); i++) { if (_itemList.GetItem(i) is { } cell) @@ -713,8 +854,65 @@ public sealed class VendorUiController : IRetainedPanelController private void SetActionButtonsEnabled(bool enabled) { - if (_buyButton is not null) _buyButton.Enabled = enabled; + _buyEnabledBySelection = enabled; if (_addButton is not null) _addButton.Enabled = enabled; + RecomputeBuyButtonEnabled(); + } + + /// + /// Slice 6.3: the Buy button's enabled state is the CONJUNCTION of "is + /// something selected" (, set by + /// ) and "is the shared inventory/ + /// use gate free right now" (). + /// The Add button (staging, contract decision 6 — unwired this pass) + /// stays selection-only. Called on every selection change AND on every + /// tick, so the + /// button disables the instant + /// takes its reservation and re-enables on the matching completion — + /// no per-frame polling. + /// + private void RecomputeBuyButtonEnabled() + { + if (_buyButton is not null) + _buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest; + } + + private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled(); + + /// + /// Slice 6.3: retail gmVendorUI::BuySingleItem (pc:201661). + /// Reads the CURRENT globally-selected shop item and the CURRENT split + /// quantity, then dispatches a single-item purchase through the shared + /// use/inventory reservation. Client-side affordability/capacity + /// pre-checks are deliberately NOT ported (research doc's open question + /// 1: the server is authoritative either way and pre-checks are latency/ + /// UX polish, not correctness — deferred as a fast follow-up if the + /// round-trip lag on a refused purchase is noticeable live). + /// + private void BuySelectedItem() + { + if (_selection.SelectedObjectId is not { } guid) + return; + + VendorShopItem? selected = null; + foreach (VendorShopItem item in _vendor.Items) + { + if (item.ItemGuid == guid) + { + selected = item; + break; + } + } + if (selected is not { } shopItem) + return; + + uint stackSize = (uint)Math.Max(shopItem.DescStackSize ?? 1, 1); + uint quantity = _splitQuantity.GetObjectSplitSize(shopItem.ItemGuid, guid, stackSize); + _itemInteraction.TryBuy( + _vendor.VendorId, + shopItem.ItemGuid, + (int)quantity, + _vendor.Profile.AlternateCurrencyWcid); } private void ClearContent() @@ -724,7 +922,13 @@ public sealed class VendorUiController : IRetainedPanelController _typeMenu.Items = Array.Empty(); _typeMenu.Selected = null; _itemList.Flush(); - ClearSelection(); + // Local widget hygiene only — does NOT touch the global selection. + // This runs from the constructor (before any vendor is ever open) + // and cannot assume whatever SelectionState.SelectedObjectId + // currently holds belongs to this panel. The actual "vendor session + // close clears a vendor-owned selection" behavior is OnObjectRemoved + // reacting to VendorShopItemMaterializer's removal, not this method. + ClearSelectionDisplay(); } private static void SetPlainText(UiText text, string value) @@ -740,12 +944,18 @@ public sealed class VendorUiController : IRetainedPanelController if (_disposed) return; _disposed = true; _vendor.Changed -= OnVendorChanged; + _selection.Changed -= OnSelectionTransition; + _objects.ObjectRemoved -= OnObjectRemoved; + _itemInteraction.StateChanged -= OnInteractionStateChanged; RetailTabBinding.SetClick(_itemsTab, null); RetailTabBinding.SetClick(_buyingTab, null); RetailTabBinding.SetClick(_sellingTab, null); _typeMenu.OnSelect = null; _typeMenu.ButtonLabelProvider = null; + _itemList.ExamineItemRequested = null; if (_close is not null) _close.OnClick = null; + if (_buyButton is not null) + _buyButton.OnClick = null; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index efbebf3f..84c3a728 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -112,7 +112,11 @@ public sealed record ToolbarRuntimeBindings( Action SendQueryHealth, Action SendQueryItemMana, Func PlayerGuid, - Action? SendPutItemInContainer); + Action? SendPutItemInContainer, + // Slice 6.2: composed at InteractionRetainedUiComposition from + // d.Inventory.Vendor + d.Inventory.Objects — SelectedObjectController's + // vendor-owned split-exempt-seed predicate (research doc §C.1). + Func IsVendorSplitExempt); public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider); @@ -170,7 +174,15 @@ public sealed record AppraisalRuntimeBindings( public sealed record VendorRuntimeBindings( VendorState State, - Func ResolveIcon); + Func ResolveIcon, + // Slice 6.1: shared examine routing — VendorUiController.ExamineItem + // calls ItemInteraction.ExamineSelectedOrEnterMode the same way + // ExternalContainerRuntimeBindings.ItemInteraction's consumer does. + ItemInteractionController ItemInteraction, + // Slice 6.2: the canonical selection authority — every sibling binding + // record (Radar/Magic/Toolbar/Inventory/ExternalContainer) already + // carries this; Vendor was the one outlier (research doc §C.1). + SelectionState Selection); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -763,7 +775,8 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.DefaultFont, StackSplitQuantity, handler => b.Objects.ObjectUpdated += handler, - handler => b.Objects.ObjectUpdated -= handler); + handler => b.Objects.ObjectUpdated -= handler, + b.IsVendorSplitExempt); UiElement root = layout.Root; RetailWindowHandle handle = RetailWindowFrame.Mount( @@ -1994,6 +2007,9 @@ public sealed class RetailUiRuntime : IDisposable b.ResolveIcon, _bindings.Inventory.Objects, _bindings.Inventory.PlayerGuid, + b.ItemInteraction, + b.Selection, + StackSplitQuantity, _bindings.Assets.DefaultFont, _bindings.Assets.DebugFont, _bindings.Assets.ResolveSprite, diff --git a/src/AcDream.Core.Net/Messages/VendorRequests.cs b/src/AcDream.Core.Net/Messages/VendorRequests.cs new file mode 100644 index 00000000..28d05b29 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/VendorRequests.cs @@ -0,0 +1,110 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound vendor GameActions. Slice 6.3: Buy (0x005F) only — Sell +/// (0x0060) is research-only scope (Slice 6 research doc §A.3), not +/// implemented here. +/// +/// +/// Wire layout, confirmed FOUR ways with zero disagreement (research doc +/// §A.1: ACE's reader, Chorizite's generated reader/writer, holtburger's +/// independent client, and the retail decompiled sender — +/// CM_Vendor::Event_Buy, pc:689288, 0x006AA0F0): +/// +/// u32 0xF7B1 // GameAction envelope +/// u32 gameActionSequence +/// u32 0x005F // Buy opcode +/// u32 vendorGuid +/// u32 itemCount +/// per item: +/// i32 amount // quantity to buy (plain positive int32, +/// // NOT ItemProfile's packed sign-extended +/// // supply-count field) +/// u32 objectGuid // the SHOP ITEM's guid +/// u32 alternateCurrencyId // TRAILING — 0 for a pyreal vendor +/// +/// +/// +/// +/// The trailing alternateCurrencyId field — a deliberate, +/// evidence-backed divergence from ACE. Retail's client +/// (CM_Vendor::Event_Buy) writes this field on EVERY Buy, after the +/// packed item list, every time (pc:689335-689336). ACE's current +/// server-side reader has the matching line PRESENT but COMMENTED OUT +/// (GameActionBuyItems.cs:32, +/// //var altCurrencyWcid = message.Payload.ReadUInt32();) — it simply +/// never reads the trailing bytes. holtburger, a real client written and +/// tested against ACE's actual accepted wire shape, omits the field +/// entirely and round-trips fine against ACE. Both are correct for what +/// they target: ACE demonstrably does not NEED this field today. We port +/// the field anyway because retail — the top oracle per this project's +/// CLAUDE.md — sends it unconditionally, it costs one u32, and it +/// costs ACE nothing to ignore (forward-compatible with any future ACE +/// build that un-comments its read). Do not "fix" this by dropping the +/// field without re-reading +/// docs/research/2026-08-08-slice6-vendor-transactions-research.md +/// §A.1 first — that document's "Resolution of the ACE/holtburger vs. +/// retail disagreement" section is the full reasoning trail. +/// +/// +public static class VendorRequests +{ + public const uint GameActionEnvelope = 0xF7B1u; + public const uint BuyOpcode = 0x005Fu; + + /// + /// Build a Buy GameAction for — retail's + /// CM_Vendor::Event_Buy(vendorGuid, &list, currencyId). Slice + /// 6.3's Buy button always passes a ONE-entry list (retail + /// BuySingleItem, pc:201661, has no staging-list + /// dependency — see the Slice 6 research doc §B.1); the list shape is + /// kept general because that is literally the wire message's own shape + /// (a future "Buy All" staged-purchase path would reuse this builder + /// unchanged, not because Slice 6.3 needs it today). + /// + public static byte[] BuildBuy( + uint gameActionSequence, + uint vendorGuid, + IReadOnlyList<(int Amount, uint ItemGuid)> items, + uint alternateCurrencyId) + { + ArgumentNullException.ThrowIfNull(items); + + int itemCount = items.Count; + byte[] body = new byte[24 + (itemCount * 8)]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), BuyOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), vendorGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)itemCount); + + int offset = 20; + for (int i = 0; i < itemCount; i++) + { + (int amount, uint itemGuid) = items[i]; + BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset), amount); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 4), itemGuid); + offset += 8; + } + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset), alternateCurrencyId); + return body; + } + + /// Convenience overload for the single-item Buy Slice 6.3 sends. + public static byte[] BuildBuy( + uint gameActionSequence, + uint vendorGuid, + int amount, + uint itemGuid, + uint alternateCurrencyId) + => BuildBuy( + gameActionSequence, + vendorGuid, + new[] { (amount, itemGuid) }, + alternateCurrencyId); +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index ca01d9ed..1c4f8f0a 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2393,6 +2393,19 @@ public sealed class WorldSession : IDisposable SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid)); } + /// + /// Slice 6.3: send retail Buy (0x005F) — a single-item purchase, retail + /// CM_Vendor::Event_Buy (pc:689288). See + /// for the wire layout and the deliberate + /// trailing alternateCurrencyId field (ported for retail + /// fidelity; ACE's server ignores it today). + /// + public void SendBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId) + { + uint seq = NextGameActionSequence(); + SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId)); + } + /// Send retail IdentifyObject/Appraise (0x00C8). public void SendAppraise(uint targetGuid) { diff --git a/src/AcDream.Core/Items/VendorSplitPolicy.cs b/src/AcDream.Core/Items/VendorSplitPolicy.cs new file mode 100644 index 00000000..8557fea1 --- /dev/null +++ b/src/AcDream.Core/Items/VendorSplitPolicy.cs @@ -0,0 +1,45 @@ +namespace AcDream.Core.Items; + +/// +/// Retail's vendor-owned stack-split-seed rule — the SHARED mask check +/// gmToolbarUI::HandleSelectionChanged applies when seeding +/// GenItemHolder::splitSize for a vendor-owned selection +/// (pc:198779-198790, mask literal at pc:198784): 1 unit if +/// the item's type intersects the mask, else the item's own authored stack +/// size. +/// +/// +/// Single source of truth for the 0xDC41CB0 mask (Slice 6.2, per the +/// research doc's §C.1 open question). Before this class existed the mask +/// was ported once, privately, inside VendorUiController +/// (display-only price/name text). SelectedObjectController's real +/// seeding path (the toolbar quantity slider — the actual retail mechanism +/// this display text mirrors) needs the SAME check; both now call this +/// class instead of either carrying its own copy. +/// +/// +public static class VendorSplitPolicy +{ + public const uint SplitExemptMask = 0x0DC41CB0u; + + /// + /// True when intersects + /// — a vendor-owned selection of this type + /// always seeds/prices/names as quantity 1, regardless of its authored + /// stack size. + /// + public static bool IsSplitExempt(ItemType itemType) => + ((uint)itemType & SplitExemptMask) != 0u; + + /// + /// The quantity retail prices/names/seeds a VENDOR-OWNED selection at: + /// 1 if intersects , + /// else (0/absent treated as 1, + /// matching ItemHolder::GetObjectSplitSize's own + /// stackSize==0 -> 1 floor, pc:401473-401476). + /// + public static int SeedQuantity(ItemType itemType, int? authoredStackSize) => + IsSplitExempt(itemType) + ? 1 + : authoredStackSize is { } size && size > 0 ? size : 1; +} diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index 22114ac2..d1abebc1 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -13,6 +13,11 @@ public enum SelectionChangeSource Toolbar, Keyboard, Plugin, + // Slice 6.2: a vendor shop-list row click — research doc §B.4 confirms + // vendor-context selections flow through the SAME global + // ACCWeenieObject::SetSelectedObject primitive as every other origin + // (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one. + Vendor, } public enum SelectionChangeReason diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index ebba7eb4..9769b844 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -17,7 +17,13 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( long ShortcutDispatchFailureCount, long TransactionDispatchFailureCount, // Slice 5.3: the sole open vendor shop id, 0 when no session is open. - uint VendorId) + uint VendorId, + // Slice 6.1: guids VendorShopItemMaterializer currently owns in + // ClientObjectTable. Must reach 0 alongside VendorId — a nonzero count + // here with VendorId already 0 would mean materialized shop items + // outlived their session (the exact regression the removal-lifecycle + // requirement guards against). + int MaterializedVendorItemCount) { public bool IsConverged => IsDisposed @@ -30,7 +36,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( && ItemManaCount == 0 && ShortcutCount == 0 && ShortcutSubscriberCount == 0 - && VendorId == 0u; + && VendorId == 0u + && MaterializedVendorItemCount == 0; } /// @@ -58,6 +65,13 @@ public sealed class RuntimeInventoryState : IDisposable // Changed event for presentation observers" shape, generation-gated // and torn down alongside the rest of this owner's children. Vendor = new VendorState(); + // Slice 6.1: materializes/retires ApproachVendor shop items into the + // SAME ClientObjectTable this owner exposes as Objects — see + // VendorShopItemMaterializer's class doc for why it subscribes to + // Vendor.Changed directly rather than the ApproachVendor wire + // handler (it must also react to RuntimeVendorRangeQuery's + // client-local Close() and this owner's own Reset()/Dispose()). + VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects); View = new InventoryStateView(this); } @@ -67,6 +81,7 @@ public sealed class RuntimeInventoryState : IDisposable public ShortcutStore Shortcuts { get; } public InventoryTransactionState Transactions { get; } public VendorState Vendor { get; } + public VendorShopItemMaterializer VendorItems { get; } public IRuntimeInventoryStateView View { get; } public bool IsDisposed => _disposed; @@ -83,7 +98,8 @@ public sealed class RuntimeInventoryState : IDisposable Shortcuts.SubscriberCount, Shortcuts.DispatchFailureCount, Transactions.DispatchFailureCount, - Vendor.VendorId); + Vendor.VendorId, + VendorItems.OwnedCount); public void ResetExternalContainer() => ExternalContainers.Reset(); public void ResetTransactions() => Transactions.ResetSession(); @@ -155,7 +171,13 @@ public sealed class RuntimeInventoryState : IDisposable try { Try(() => ExternalContainers.Reset(), ref failures); + // Vendor.Reset() must run BEFORE VendorItems.Dispose() — + // Reset() fires Changed synchronously, which is what drives the + // materializer's own retire pass; disposing first would + // unsubscribe before that pass runs and strand materialized + // items in ClientObjectTable past session teardown. Try(() => Vendor.Reset(), ref failures); + Try(VendorItems.Dispose, ref failures); Try(ItemMana.Clear, ref failures); Try(Shortcuts.Dispose, ref failures); Try(Transactions.Dispose, ref failures); diff --git a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs new file mode 100644 index 00000000..728b26f5 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Items; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Slice 6.1: materializes each ApproachVendor shop-list item into the +/// SAME Runtime issues identity into for +/// spawned entities (J3.5's "Runtime issues identity before App hydration" +/// ownership — see ObjectTableWiring.ApplyEntitySpawn, which is +/// invoked from RuntimeEntityObjectLifetime, never directly from +/// GameEventWiring/AcDream.Core.Net). Owned by +/// alongside the +/// it observes and the exact instance +/// RuntimeEntityObjectLifetime owns. +/// +/// +/// Why subscribe to instead of the +/// ApproachVendor wire handler. A listener registered only on the +/// ApproachVendor GameEvent (GameEventWiring.cs) would only ever see +/// the wire-driven Opened/Refreshed transitions. Two of the +/// four transition kinds never touch the wire at all: +/// RuntimeVendorRangeQuery.EnforceRange's distance-triggered +/// and this owner's own +/// teardown (session reset / portal-out / +/// logout / final disposal). Subscribing directly to Changed reacts +/// uniformly to every source, matching the Slice 6 contract's "on session +/// Close/Replace/Reset, the materialized shop items leave the table." +/// +/// +/// +/// Retail anchor. gmVendorUI::OpenVendor materializes each +/// list item as a full CWeenieObject in ClientObjMaintSystem +/// (research doc docs/research/2026-08-08-slice5-vendor-browse-research.md +/// §A.2 point 4, pc:203720-203748) — vendor items are ordinary client +/// objects with no spatial presence, not a separate lightweight record. +/// §C.1 of the Slice 6 research doc names the load-bearing consequence: +/// without a live entry, +/// SelectedObjectController's name/stack resolvers (Slice 6.2) and +/// AppraisalUiController.Apply (this slice's examine wiring) both +/// come up blank. gmVendorUI::CloseVendor (pc:202080) is the retail +/// teardown site this class mirrors for the removal half. +/// +/// +/// +/// Diff, not blanket remove-then-reinsert. ACE's own +/// Vendor.LoadInventory +/// (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:126-172) +/// assigns each DefaultItemsForSale entry a real server guid ONCE, at +/// first load — that guid is stable across every later +/// ApproachVendor for the SAME vendor, so a post-buy/sell +/// Refreshed snapshot reuses the same guids for stock that's still in +/// supply. Removing and immediately re-adding a still-present guid would +/// fire a spurious ObjectRemoved/ObjectAdded pair for every +/// unrelated line item on every refresh — a UI panel holding that guid (an +/// open appraisal window, Slice 6.2's SelectionState) would see a +/// false "it's gone" notice. Only guids that left this vendor's stock (sold +/// out, or a DIFFERENT vendor entirely superseded this one) are removed; +/// every still-present guid is merge-upserted via the ordinary +/// path (a harmless no-op if +/// genuinely unchanged, a field refresh otherwise). On a supersede (a +/// DIFFERENT vendor's Opened transition), the retire pass runs before +/// the materialize pass in the SAME call, satisfying the contract's "on +/// REPLACE, the old vendor's items go before the new ones land." +/// +/// +/// +/// Collision policy. ACE's UniqueItemsForSale +/// (Vendor.cs:34,638) keeps the EXACT WorldObject — and +/// therefore the exact guid — a player last held when they sold it to this +/// vendor. If that guid is already present in +/// for a reason THIS materializer did not itself create (a live entity, an +/// item still sitting in someone's inventory/equipment, or any other +/// collision), -ing vendor-owned +/// over it would silently reparent a real object +/// into the vendor's container. Per the project's no-workarounds-without- +/// approval rule this is a SKIP, not a best-effort overwrite: a guid this +/// class did not itself add to its owned set on the previous cycle is +/// treated as owned by someone else and is left completely untouched (not +/// materialized, not tracked, not later removed by this class either). The +/// vendor row still renders correctly regardless — +/// VendorUiController reads display fields straight off +/// , never through +/// — only that one item's status-bar/appraisal projection stays whatever it +/// already was, which is safe by construction and never corrupts a real +/// object's ownership. +/// +/// +public sealed class VendorShopItemMaterializer : IDisposable +{ + private readonly VendorState _vendor; + private readonly ClientObjectTable _objects; + private readonly HashSet _ownedGuids = new(); + private bool _disposed; + + public VendorShopItemMaterializer(VendorState vendor, ClientObjectTable objects) + { + _vendor = vendor ?? throw new ArgumentNullException(nameof(vendor)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _vendor.Changed += OnVendorTransition; + } + + /// + /// Count of guids this materializer currently owns in + /// . Zero once the session is closed/ + /// reset — feeds 's + /// convergence gate. + /// + public int OwnedCount => _ownedGuids.Count; + + /// True if is a shop item this materializer put in the table. + public bool Owns(uint guid) => _ownedGuids.Contains(guid); + + private void OnVendorTransition(VendorTransition transition) + { + IReadOnlyList currentItems = _vendor.Items; + var stillListed = new HashSet(currentItems.Count); + foreach (VendorShopItem item in currentItems) + stillListed.Add(item.ItemGuid); + + // Retire every guid we own that fell out of the new snapshot (sold + // out, session closed/reset, or a different vendor superseded this + // one — in every one of those cases stillListed is missing it). + // Runs BEFORE the materialize loop below: "on REPLACE, the old + // vendor's items go before the new ones land." + foreach (uint guid in _ownedGuids) + { + if (!stillListed.Contains(guid)) + _objects.Remove(guid); + } + + var nextOwned = new HashSet(currentItems.Count); + foreach (VendorShopItem item in currentItems) + { + bool ownedAlready = _ownedGuids.Contains(item.ItemGuid); + if (!ownedAlready && _objects.Get(item.ItemGuid) is not null) + { + // Collision guard — see class doc. Never take ownership of a + // guid this materializer did not itself add. + Console.Error.WriteLine( + "[VendorShopItemMaterializer] skipped guid=0x" + + item.ItemGuid.ToString("X8") + + " — already present in ClientObjectTable and not " + + "owned by this vendor session."); + continue; + } + + _objects.Ingest(ToWeenieData(item, transition.VendorId)); + nextOwned.Add(item.ItemGuid); + } + + _ownedGuids.Clear(); + foreach (uint guid in nextOwned) + _ownedGuids.Add(guid); + } + + /// + /// Field mapping from the domain-shaped to + /// the wire-shaped merge patch + /// expects. — not + /// , ItemProfile's separate packed + /// SUPPLY-count field — is the wire equivalent of an ordinary + /// CreateObject's own StackSize field (see the doc comment on + /// ). Every field + /// doesn't carry (capacity, equip mask, + /// combat use, etc.) is passed null, leaving it untouched on a refresh + /// and defaulted on a fresh object per 's + /// null-preserving merge contract. + /// + private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new( + Guid: item.ItemGuid, + Name: item.Name, + Type: item.ItemType is { } t ? (ItemType)t : null, + WeenieClassId: item.WeenieClassId, + IconId: item.IconId, + IconOverlayId: item.IconOverlayId, + IconUnderlayId: item.IconUnderlayId, + Effects: item.Effects, + Value: item.Value, + StackSize: item.DescStackSize, + StackSizeMax: null, + Burden: null, + ContainerId: vendorId, + WielderId: 0u, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null, + PluralName: item.PluralName); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _vendor.Changed -= OnVendorTransition; + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 11a9740a..c98935e8 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -27,6 +27,7 @@ public sealed class ItemInteractionControllerTests public readonly List Drops = new(); public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List<(uint Target, uint Item, uint Amount)> Gives = new(); + public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new(); public readonly List Toasts = new(); public readonly List SystemMessages = new(); public readonly List CombatModeRequests = new(); @@ -94,7 +95,9 @@ public sealed class ItemInteractionControllerTests }, combatState: Combat, sendChangeCombatMode: CombatModeRequests.Add, - requestUse: requestUse); + requestUse: requestUse, + sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) => + Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId))); } public ItemInteractionController Controller { get; } @@ -2145,4 +2148,123 @@ public sealed class ItemInteractionControllerTests Assert.Equal(0, h.Controller.BusyCount); Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind); } + + // ── Slice 6.3: TryBuy ─────────────────────────────────────────────── + + [Fact] + public void TryBuy_Succeeds_SendsBuyAndTakesTheSharedUseReservation() + { + var h = new Harness(); + + bool result = h.Controller.TryBuy( + vendorGuid: 0x40001000u, + itemGuid: 0x50002000u, + amount: 1, + alternateCurrencyId: 0u); + + Assert.True(result); + Assert.Equal( + new[] { (0x40001000u, 0x50002000u, 1, 0u) }, + h.Buys); + // BeginUseRequestReservation increments BusyCount synchronously, + // before/independent of any wire response -- this is what makes the + // Buy button disable immediately (research doc §A.4: "no second + // gate", the SAME BusyCount>0 check every other request rides). + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void TryBuy_StackedQuantity_ForwardsTheExactAmount() + { + var h = new Harness(); + + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 25, 0u)); + + Assert.Equal(25, h.Buys.Single().Amount); + } + + [Fact] + public void TryBuy_AlternateCurrencyVendor_ForwardsTheCurrencyWcid() + { + var h = new Harness(); + + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0x12345678u)); + + Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId); + } + + [Fact] + public void TryBuy_WhileAnotherRequestIsBusy_IsRejectedAndSendsNothing() + { + var h = new Harness(); + h.Controller.IncrementBusyCount(); // simulates any other in-flight request + + bool result = h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u); + + Assert.False(result); + Assert.Empty(h.Buys); + Assert.Equal(1, h.Controller.BusyCount); // unchanged -- no second reservation taken + } + + [Fact] + public void TryBuy_ASecondBuyWhileTheFirstIsInFlight_IsRejected() + { + var h = new Harness(); + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u)); + + bool second = h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u); + + Assert.False(second); + Assert.Single(h.Buys); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Theory] + [InlineData(0u, 0x50002000u, 1)] + [InlineData(0x40001000u, 0u, 1)] + [InlineData(0x40001000u, 0x50002000u, 0)] + [InlineData(0x40001000u, 0x50002000u, -1)] + public void TryBuy_InvalidArguments_IsRejectedWithoutTakingAReservation( + uint vendorGuid, uint itemGuid, int amount) + { + var h = new Harness(); + + bool result = h.Controller.TryBuy(vendorGuid, itemGuid, amount, 0u); + + Assert.False(result); + Assert.Empty(h.Buys); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void TryBuy_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests() + { + // Research doc §A.4: UseDone (0x01C7) is the completion signal for + // Buy, resolved through the SAME RuntimeInteractionTransactionState. + // CompleteUse the existing UseDone handler already calls -- no new + // completion plumbing needed on the receive side. + var h = new Harness(); + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u)); + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.CompleteUse(0); + + Assert.Equal(0, h.Controller.BusyCount); + // The gate is free again -- a second Buy can now proceed. + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u)); + Assert.Equal(2, h.Buys.Count); + } + + [Fact] + public void TryBuy_FailedUseDone_AlsoReleasesTheReservation() + { + // A.2's failure paths all still end in exactly one SendUseDoneEvent + // -- success or failure, the reservation resolves the same way. + var h = new Harness(); + Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u)); + + h.Controller.CompleteUse(0x0009u); // an arbitrary nonzero WeenieError + + Assert.Equal(0, h.Controller.BusyCount); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index 57b3bca3..2bc49ce5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -94,6 +94,9 @@ public class SelectedObjectControllerTests public readonly Dictionary HasHealthMap = new(); public readonly Dictionary ManaMap = new(); public readonly Dictionary StackMap = new(); + // Slice 6.2: vendor-owned split-exempt predicate — see + // SelectedObjectController.Bind's isVendorSplitExempt parameter. + public readonly Dictionary VendorSplitExemptMap = new(); public void FireSelection(uint? g) { @@ -135,7 +138,8 @@ public class SelectedObjectControllerTests unsubscribeObjectUpdated: h => { if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null; - }); + }, + isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v); } // ── B1: Bind initialisation ────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 32c92029..44ea0853 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -4,6 +4,8 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Properties; +using AcDream.Core.Selection; +using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; @@ -41,13 +43,26 @@ public sealed class VendorUiControllerTests Visible = false, }); + var objects = new ClientObjectTable(); + using var itemInteraction = new ItemInteractionController( + objects, + new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), + new InteractionState(), + playerGuid: static () => 0u, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); VendorUiController? controller = VendorUiController.Bind( layout, new VendorState(), window, static (_, _, _, _, _) => 0u, - new ClientObjectTable(), + objects, static () => 0u, + itemInteraction, + new SelectionState(), + new StackSplitQuantityState(), datFont: null, debugFont: null, static _ => (0u, 0, 0)); @@ -60,9 +75,11 @@ public sealed class VendorUiControllerTests // F2/F3: a deterministic non-zero player coin total so the cost-text // "(you have ...)" tail is assertable. public const int DefaultPlayerCoinValue = 1500; - private const uint PlayerGuid = 0x50000001u; + public const uint PlayerGuid = 0x50000001u; public readonly VendorState State = new(); + public readonly SelectionState Selection = new(); + public readonly StackSplitQuantityState SplitQuantity = new(); public readonly UiRoot Screen = new() { Width = 800f, Height = 600f }; public readonly ClientObjectTable Objects = new(); public readonly UiItemList ItemList = new(); @@ -81,6 +98,9 @@ public sealed class VendorUiControllerTests public readonly UiButton AddButton; public readonly RetailWindowHandle Window; public readonly VendorUiController Controller; + public readonly List Examines = new(); + public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new(); + public readonly ItemInteractionController ItemInteraction; public Harness() { @@ -145,6 +165,19 @@ public sealed class VendorUiControllerTests Resizable = false, }); + ItemInteraction = new ItemInteractionController( + Objects, + new RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), + new InteractionState(), + playerGuid: static () => PlayerGuid, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + sendExamine: Examines.Add, + sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) => + Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId))); + Controller = VendorUiController.Bind( layout, State, @@ -155,6 +188,9 @@ public sealed class VendorUiControllerTests static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects, Objects, static () => PlayerGuid, + ItemInteraction, + Selection, + SplitQuantity, datFont: null, debugFont: null, static _ => (0u, 0, 0))!; @@ -611,4 +647,204 @@ public sealed class VendorUiControllerTests Assert.False(h.SellingPage.Visible); Assert.Equal(1, h.ItemList.GetNumUIItems()); } + + [Fact] + public void RightClickShopRow_SelectsAndRoutesThroughItemInteractionExamine() + { + // Slice 6.1: mirrors ExternalContainerControllerTests' + // RightClickLoot_selectsAndExaminesWithoutPickingUp — the shop list + // wires ExamineItemRequested the same way the container lists do, + // now that shop items are materialized into ClientObjectTable + // (AP-161 finding #2's examine gap closes here). + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5), + }); + // Food, not the auto-selected Armor default, so the assertion below + // proves the right click itself drove the selection. + UiItemSlot? foodCell = null; + object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload; + h.TypeMenu.OnSelect!.Invoke(foodPayload); + foodCell = h.ItemList.GetItem(0); + Assert.Equal(FoodItemGuid, foodCell!.ItemId); + + foodCell.OnEvent(new UiEvent(0u, foodCell, UiEventType.RightClick)); + + Assert.Equal(new[] { FoodItemGuid }, h.Examines); + Assert.Equal("Bread", GetText(h.ItemNameText)); + Assert.True(foodCell.Selected); + } + + // ── Slice 6.3: Buy button ──────────────────────────────────────────── + + [Fact] + public void BuyButton_Press_NonStackedItem_BuysQuantityOne() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + // F4 auto-selected the sole item; its own stack size (default/absent) + // is <=1, so StackSplitQuantityState was never seeded above 1 either + // (this harness doesn't mount SelectedObjectController, so the split + // state starts at its class default Value=1/Maximum=1 — exactly what + // production would also show for a non-stacked item). + + h.BuyButton.OnClick!.Invoke(); + + Assert.Equal( + new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, + h.Buys); + } + + [Fact] + public void BuyButton_Press_StackedItem_UsesTheLiveSplitSliderQuantity() + { + // Slice 6.3: BuySingleItem (pc:201674-201681) reads the CURRENT + // slider value, not the full stack. This harness doesn't mount + // SelectedObjectController (the toolbar owns that seeding in + // production), so the test seeds SplitQuantity directly to stand in + // for "the player selected this item, then dragged the slider to + // 25" before pressing Buy. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem( + StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000, + DescStackSize: 100), + }); + h.SplitQuantity.Reset(100u, initialValue: 25u); + + h.BuyButton.OnClick!.Invoke(); + + Assert.Equal(25, h.Buys.Single().Amount); + } + + [Fact] + public void BuyButton_Press_AlternateCurrencyVendor_ForwardsTheVendorsTradeWcid() + { + var h = new Harness(); + h.State.Apply( + VendorGuid, + Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 500u), + new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + + h.BuyButton.OnClick!.Invoke(); + + Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId); + } + + [Fact] + public void BuyButton_DisablesTheInstantAPurchaseIsInFlight_AndReenablesOnCompletion() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + Assert.True(h.BuyButton.Enabled); + + h.BuyButton.OnClick!.Invoke(); + + // TryBuy's reservation increments BusyCount synchronously, before + // any wire response — the button must reflect that immediately, + // with no per-frame polling (ItemInteractionController.StateChanged + // drives RecomputeBuyButtonEnabled). + Assert.False(h.BuyButton.Enabled); + + h.ItemInteraction.CompleteUse(0); + + Assert.True(h.BuyButton.Enabled); + } + + [Fact] + public void BuyButton_NoSelection_PressDoesNothing() + { + var h = new Harness(); + + h.BuyButton.OnClick!.Invoke(); + + Assert.Empty(h.Buys); + } + + [Fact] + public void ReconciliationRoundTrip_MoneyCreateObjectAndApproachVendorRefresh_FlowThroughExistingMachinery() + { + // Slice 6.3: verifies the loop end-to-end with synthetic inbound + // messages, adding no new owner (research doc §C.4/§A.2 point 4): + // (1) a money property update applies to the SAME ClientObjectTable + // the vendor panel reads live for its cost text, + // (2) the purchase lands in the player's inventory via the ordinary + // CreateObject merge-upsert (ClientObjectTable.Ingest), + // (3) the vendor's post-buy ApproachVendor refresh + // (VendorStateTransitionKind.Refreshed) rebuilds the panel — + // here the bought-out item leaves the vendor's stock entirely + // (the common single-item-purchase case), so the rebuild is + // externally observable: the item leaves the list and the + // selection/buttons clear. + var h = new Harness(); + const uint PurchasedItemGuid = 0x60000900u; + h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + Assert.Equal(1, h.ItemList.GetNumUIItems()); + Assert.True(h.BuyButton.Enabled); + + h.BuyButton.OnClick!.Invoke(); + Assert.Single(h.Buys); + Assert.False(h.BuyButton.Enabled); + + // (1) Money: PrivateUpdatePropertyInt(CoinValue) — the exact path + // ObjectTableWiring's PlayerIntPropertyUpdated handler calls. + int newCoinValue = Harness.DefaultPlayerCoinValue - 500; + h.Objects.UpdateIntProperty(Harness.PlayerGuid, (uint)PropertyInt.CoinValue, newCoinValue); + + // (2) CreateObject: the purchased item lands in the player's own + // inventory — the ordinary Ingest merge-upsert every CreateObject + // uses (ObjectTableWiring.ApplyEntitySpawn), landing here with the + // player as its container. + h.Objects.Ingest(new WeenieData( + Guid: PurchasedItemGuid, + Name: "Chainmail", + Type: ItemType.Armor, + WeenieClassId: 2u, + IconId: 200u, + IconOverlayId: 0u, + IconUnderlayId: 0u, + Effects: 0u, + Value: 500, + StackSize: null, + StackSizeMax: null, + Burden: null, + ContainerId: Harness.PlayerGuid, + WielderId: 0u, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null)); + + // (3) ApproachVendor refresh: sold out of the ONLY armor stack, so + // the SAME vendor's next snapshot no longer lists it. + h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), System.Array.Empty()); + + // (4) UseDone completes the reservation. + h.ItemInteraction.CompleteUse(0); + + Assert.Equal(newCoinValue, h.Objects.Get(Harness.PlayerGuid)?.Properties.GetInt((uint)PropertyInt.CoinValue)); + Assert.Equal(Harness.PlayerGuid, h.Objects.Get(PurchasedItemGuid)?.ContainerId); + Assert.Equal(0, h.ItemList.GetNumUIItems()); + Assert.Equal(string.Empty, GetText(h.ItemNameText)); + Assert.False(h.BuyButton.Enabled); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs new file mode 100644 index 00000000..f23d9178 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Slice 6.3 golden-byte coverage for the outbound Buy (0x005F) +/// builder — research doc §A.1's four-way-confirmed wire layout, including +/// the trailing alternateCurrencyId field the real retail client +/// sends but ACE's reader currently ignores. +/// +public sealed class VendorRequestsTests +{ + [Fact] + public void BuildBuy_SingleItem_WritesEnvelopeSequenceOpcodeVendorCountAndItem() + { + byte[] body = VendorRequests.BuildBuy( + gameActionSequence: 9, + vendorGuid: 0x40001000u, + amount: 1, + itemGuid: 0x50002000u, + alternateCurrencyId: 0u); + + // envelope(4) + seq(4) + opcode(4) + vendorGuid(4) + itemCount(4) + // + 1*(amount(4)+guid(4)) + trailing currency(4) = 32. + Assert.Equal(32, body.Length); + Assert.Equal(VendorRequests.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0))); + Assert.Equal(9u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(VendorRequests.BuyOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x005Fu, VendorRequests.BuyOpcode); + Assert.Equal(0x40001000u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + Assert.Equal(1, + BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20))); + Assert.Equal(0x50002000u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); + // Trailing alternateCurrencyId — present even for a pyreal (0) vendor, + // matching retail's CM_Vendor::Event_Buy which writes it unconditionally. + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28))); + } + + [Fact] + public void BuildBuy_StackedItem_WritesTheSplitSliderQuantityAsAPlainPositiveAmount() + { + // amount is NOT ItemProfile's packed sign-extended supply-count + // field -- it's a plain positive int32 (research doc §A.1). + byte[] body = VendorRequests.BuildBuy( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + amount: 25, + itemGuid: 0x50002001u, + alternateCurrencyId: 0u); + + Assert.Equal(25, + BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20))); + } + + [Fact] + public void BuildBuy_AlternateCurrencyVendor_WritesTheVendorsTradeWcidTrailing() + { + byte[] body = VendorRequests.BuildBuy( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + amount: 1, + itemGuid: 0x50002000u, + alternateCurrencyId: 0x12345678u); + + Assert.Equal(0x12345678u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28))); + } + + [Fact] + public void BuildBuy_ListOverload_MultipleItems_WritesEachAmountGuidPairInOrder() + { + byte[] body = VendorRequests.BuildBuy( + gameActionSequence: 4, + vendorGuid: 0x40001000u, + items: new (int Amount, uint ItemGuid)[] + { + (1, 0x50002000u), + (10, 0x50002001u), + }, + alternateCurrencyId: 0u); + + // 24 + 2*8 = 40. + Assert.Equal(40, body.Length); + Assert.Equal(2u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + Assert.Equal(1, + BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20))); + Assert.Equal(0x50002000u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); + Assert.Equal(10, + BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(28))); + Assert.Equal(0x50002001u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32))); + // Trailing currency still lands after every item pair. + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(36))); + } + + [Fact] + public void BuildBuy_SingleItemOverload_MatchesTheGeneralListOverload() + { + byte[] viaSingle = VendorRequests.BuildBuy( + gameActionSequence: 3, + vendorGuid: 0x40001000u, + amount: 5, + itemGuid: 0x50002000u, + alternateCurrencyId: 7u); + byte[] viaList = VendorRequests.BuildBuy( + gameActionSequence: 3, + vendorGuid: 0x40001000u, + items: new (int Amount, uint ItemGuid)[] { (5, 0x50002000u) }, + alternateCurrencyId: 7u); + + Assert.Equal(viaList, viaSingle); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs new file mode 100644 index 00000000..47835008 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// Slice 6.3 — verifies produces the same +/// wire bytes does directly, using a +/// sequence number drawn from . +/// Mirrors WorldSessionChatTests's GameActionCapture test seam. +/// +public sealed class WorldSessionVendorTests +{ + private static WorldSession NewSession() + { + var ep = new IPEndPoint(IPAddress.Loopback, 65001); + return new WorldSession(ep); + } + + [Fact] + public void SendBuy_EmitsBytesIdenticalToVendorRequestsBuildBuy() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendBuy( + vendorGuid: 0x40001000u, + itemGuid: 0x50002000u, + amount: 3, + alternateCurrencyId: 0u); + + byte[] expected = VendorRequests.BuildBuy( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + amount: 3, + itemGuid: 0x50002000u, + alternateCurrencyId: 0u); + + Assert.NotNull(captured); + Assert.Equal(expected, captured); + } + + [Fact] + public void SendBuy_IncrementsTheSharedGameActionSequenceLikeEveryOtherSend() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTalk("first"); // consumes sequence 1 + session.SendBuy(0x40001000u, 0x50002000u, 1, 0u); // should be sequence 2 + + byte[] expected = VendorRequests.BuildBuy( + gameActionSequence: 2, + vendorGuid: 0x40001000u, + amount: 1, + itemGuid: 0x50002000u, + alternateCurrencyId: 0u); + + Assert.Equal(expected, captured); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs index 6a41ca45..14eab2ea 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs @@ -198,6 +198,56 @@ public sealed class RuntimeVendorLifecycleTests Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId); } + [Fact] + public void ApproachVendorEvent_MaterializesShopItemsIntoTheOwnedObjectTable() + { + // Slice 6.1: unlike Wire()'s throwaway ClientObjectTable (used by + // the tests above, which only assert on VendorState itself), this + // wires the dispatcher against the SAME table + // RuntimeInventoryState.Objects exposes, so the + // VendorShopItemMaterializer subscription RuntimeInventoryState's + // constructor installs is exercised end-to-end from the real wire + // parse through to ClientObjectTable. + using GameRuntime runtime = Create(); + using IDisposable wiring = GameEventWiring.WireAll( + _dispatcher, + runtime.InventoryOwner.Objects, + new CombatState(), + new Spellbook(), + new ChatLog(), + vendor: runtime.InventoryOwner.Vendor); + + Dispatch(BuildApproachVendorPayload( + vendorGuid: 0x40001000u, + categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, + buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, + currencyName: "", + items: + [ + new VendorItemFixture( + 0x50002000u, 1, "Iron Sword", 42u, 0x1234u, + (uint)ItemType.Weapon, 250), + ])); + + ClientObject? shopItem = runtime.InventoryOwner.Objects.Get(0x50002000u); + Assert.NotNull(shopItem); + Assert.Equal(0x40001000u, shopItem!.ContainerId); + Assert.Equal("Iron Sword", shopItem.Name); + Assert.Equal(1, runtime.InventoryOwner.VendorItems.OwnedCount); + + runtime.InventoryOwner.Vendor.Close(); + + Assert.Null(runtime.InventoryOwner.Objects.Get(0x50002000u)); + Assert.Equal(0, runtime.InventoryOwner.VendorItems.OwnedCount); + // Close() is a client-local session end, not full disposal, so only + // the vendor-specific ownership dimensions are asserted here — the + // full IsConverged gate is exercised by DisposingInventoryOwner_ + // ClearsTheOpenVendorSession below. + RuntimeInventoryOwnershipSnapshot snapshot = runtime.InventoryOwner.CaptureOwnership(); + Assert.Equal(0u, snapshot.VendorId); + Assert.Equal(0, snapshot.MaterializedVendorItemCount); + } + [Fact] public void VendorId_IsTheLiveActiveVendorIdSeamSource() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs new file mode 100644 index 00000000..7f3408b5 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs @@ -0,0 +1,191 @@ +using AcDream.Core.Items; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Slice 6.1 — in isolation: the +/// diff/materialize/retire logic against a bare + +/// pair, independent of the wire parse +/// ( covers the end-to-end +/// ApproachVendor path). +/// +public sealed class VendorShopItemMaterializerTests +{ + private const uint VendorGuid = 0x40001000u; + private const uint OtherVendorGuid = 0x40002000u; + private const uint ItemA = 0x50002000u; + private const uint ItemB = 0x50002001u; + + private static VendorShopItem Item(uint guid, string name = "Item", int? descStackSize = null) => + new(guid, StackSize: -1, WeenieClassId: 1u, Name: name, ItemType: (uint)ItemType.Misc, + IconId: 0x1234u, Value: 10, DescStackSize: descStackSize); + + [Fact] + public void Apply_MaterializesEachShopItemWithVendorAsContainer() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Iron Sword"), Item(ItemB, "Bread") }); + + ClientObject? a = objects.Get(ItemA); + ClientObject? b = objects.Get(ItemB); + Assert.NotNull(a); + Assert.NotNull(b); + Assert.Equal(VendorGuid, a!.ContainerId); + Assert.Equal(VendorGuid, b!.ContainerId); + Assert.Equal("Iron Sword", a.Name); + Assert.Equal(2, materializer.OwnedCount); + Assert.True(materializer.Owns(ItemA)); + Assert.True(materializer.Owns(ItemB)); + } + + [Fact] + public void Close_RemovesEveryMaterializedItem() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) }); + + vendor.Close(); + + Assert.Null(objects.Get(ItemA)); + Assert.Null(objects.Get(ItemB)); + Assert.Equal(0, materializer.OwnedCount); + } + + [Fact] + public void Reset_RemovesEveryMaterializedItem() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA) }); + + vendor.Reset(); + + Assert.Null(objects.Get(ItemA)); + Assert.Equal(0, materializer.OwnedCount); + } + + [Fact] + public void DifferentVendorSupersedes_RemovesPriorVendorsItemsBeforeMaterializingTheNewOnes() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "First Vendor Item") }); + Assert.NotNull(objects.Get(ItemA)); + + const uint NewItem = 0x50003000u; + vendor.Apply(OtherVendorGuid, default, new[] { Item(NewItem, "Second Vendor Item") }); + + Assert.Null(objects.Get(ItemA)); + ClientObject? replacement = objects.Get(NewItem); + Assert.NotNull(replacement); + Assert.Equal(OtherVendorGuid, replacement!.ContainerId); + Assert.Equal(1, materializer.OwnedCount); + } + + [Fact] + public void Refreshed_SameVendor_DoesNotFireObjectRemovedForStillListedItems() + { + // A same-vendor re-approach (post-buy refresh) must not remove+ + // re-add a guid that's still in stock -- see the class doc's "diff, + // not blanket remove-then-reinsert" rationale. A UI panel holding + // the guid (an open appraisal window) would see a false "it's gone" + // notice if this regressed to blanket removal. + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail") }); + + var removed = new List(); + objects.ObjectRemoved += o => removed.Add(o.ObjectId); + + // Same vendor id re-approaches with the SAME item guid still listed + // (e.g. a post-buy refresh where this item wasn't the one bought) + // but with a refreshed field value. + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail", descStackSize: 5) }); + + Assert.Empty(removed); + Assert.Equal(1, materializer.OwnedCount); + Assert.Equal(5, objects.Get(ItemA)!.StackSize); + } + + [Fact] + public void Refreshed_ItemNoLongerListed_IsRemoved() + { + // A unique item sold out (bought up / delisted) between one + // ApproachVendor and the next same-vendor refresh. + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) }); + + vendor.Apply(VendorGuid, default, new[] { Item(ItemB) }); + + Assert.Null(objects.Get(ItemA)); + Assert.NotNull(objects.Get(ItemB)); + Assert.Equal(1, materializer.OwnedCount); + } + + [Fact] + public void CollidingGuid_AlreadyOwnedBySomethingElse_IsNeverClobbered() + { + // The Slice 6.1 collision policy: ACE's UniqueItemsForSale can list + // the EXACT guid a player last held (e.g. a sold-off item, or -- + // worst case -- any other collision). If that guid is already in + // ClientObjectTable for a reason this materializer did not itself + // create, it must be left completely untouched, not silently + // reparented into the vendor's container. + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + // Simulate a pre-existing, non-vendor-owned object at this guid -- + // e.g. a live entity, or an item still sitting in someone's + // inventory/equipment. + const uint LiveOwner = 0x60000001u; + objects.AddOrUpdate(new ClientObject + { + ObjectId = ItemA, + Name = "Definitely Not A Shop Item", + ContainerId = LiveOwner, + }); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Shop Listing With A Colliding Guid") }); + + ClientObject? survivor = objects.Get(ItemA); + Assert.NotNull(survivor); + Assert.Equal("Definitely Not A Shop Item", survivor!.Name); + Assert.Equal(LiveOwner, survivor.ContainerId); + Assert.False(materializer.Owns(ItemA)); + Assert.Equal(0, materializer.OwnedCount); + + // The collision guid must also survive session close -- since this + // materializer never claimed it, it must never remove it either. + vendor.Close(); + Assert.NotNull(objects.Get(ItemA)); + } + + [Fact] + public void Dispose_UnsubscribesFromVendorChanged() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + var materializer = new VendorShopItemMaterializer(vendor, objects); + vendor.Apply(VendorGuid, default, new[] { Item(ItemA) }); + Assert.NotNull(objects.Get(ItemA)); + + materializer.Dispose(); + + // No further reaction once disposed -- a Close() after disposal + // must not throw and must not touch the table (nothing left + // subscribed to react). + vendor.Close(); + Assert.NotNull(objects.Get(ItemA)); + } +}