From 92ea3977b68b4c14d539756b1ac3805a4129c0ce Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 8 Aug 2026 11:43:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(vendor):=20Slice=206b/6c=20=E2=80=94=20mov?= =?UTF-8?q?e-to-use,=20buy=20staging,=20selling;=20the=20vendor=20arc=20is?= =?UTF-8?q?=20functionally=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 an out-of-range Use now approaches first via the existing client-predicted BeginApproach (Pickup's far-range shape mirrored; retail's ItemHolder::UseObject @0x00588A80 has no range check and the dispatch stays immediate). C2 Add-to-List stages into the Buying tab via VendorStagingList (RemoveProfileFromList's two shapes, pc:200497-200537), Buy All sends ONE batched 0x005F and flushes staging on send exactly as retail does (SendShopEvent -> Flush, pc:204075-204076 — not UseDone-gated), and X-close over a non-empty staging list shows retail's confirm string recovered verbatim from the binary data segment (0x007b5bd8) through the existing dialog factory. C3 the Selling tab's list is the sole drop target (retail's single IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports InqAcceptability with all rejection strings recovered verbatim from the raw data segment; the sell side prices with BuyPrice (retail's inverted naming: what the vendor PAYS) and 0x0060 carries no trailing currency field, unlike Buy. C4 the status-bar reproduction test PASSES against the production toolbar mount — retail's toolbar shows count + name with the split bar and NO price parenthetical (that figure is the vendor row's own cost text); no code change, the live gate referees. C5 pack order verified correct, untouched. Register: AP-161 narrowed to its two pre-existing cosmetic gaps; AP-162 extended over Buy All; AP-164 (non-sellable bitfield unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal test, bounded), AP-166 (purse text + pending-sell highlight cosmetic) filed. Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 --- .../retail-divergence-register.md | 9 +- .../InteractionRetainedUiComposition.cs | 19 +- .../SelectionInteractionController.cs | 18 + .../UI/ItemInteractionController.cs | 95 ++- .../UI/Layout/VendorUiController.cs | 641 +++++++++++++++-- src/AcDream.App/UI/RetailUiRuntime.cs | 12 +- .../Messages/VendorRequests.cs | 50 +- src/AcDream.Core.Net/WorldSession.cs | 27 + .../Items/VendorSellAcceptability.cs | 137 ++++ src/AcDream.Core/Items/VendorStagingList.cs | 98 +++ .../SelectionInteractionControllerTests.cs | 80 ++- .../UI/ItemInteractionControllerTests.cs | 198 +++++ .../Layout/SelectedObjectControllerTests.cs | 113 +++ .../UI/Layout/VendorUiControllerTests.cs | 679 +++++++++++++++++- .../Messages/VendorRequestsTests.cs | 71 ++ .../WorldSessionVendorTests.cs | 66 ++ .../Items/VendorSellAcceptabilityTests.cs | 174 +++++ .../Items/VendorStagingListTests.cs | 169 +++++ 18 files changed, 2578 insertions(+), 78 deletions(-) create mode 100644 src/AcDream.Core/Items/VendorSellAcceptability.cs create mode 100644 src/AcDream.Core/Items/VendorStagingList.cs create mode 100644 tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a510532f..3167254f 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) — 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) +## 3. Documented approximation (AP) — 117 active rows (AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. 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,9 +288,12 @@ 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 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **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` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. 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. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `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-161 | **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **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` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. 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 | **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **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. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `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 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **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-164 | **Filed 2026-08-09, Slice 6c (selling drag-to-sell).** `VendorProfile::InqAcceptability`'s non-sellable bitfield check (`(*(uint8_t*)((char*)arg2->_bitfield)[3] & 1) != 0`, `pc:005d1aa7`, byte 3 bit 0 — i.e. bit 24, `0x01000000` — of `PublicWeenieDesc`'s packed flags) is not ported. `VendorSellAcceptability.Evaluate` only checks the item-type mask, per-unit value, and min/max value bounds. This exact bit is not currently threaded onto `ClientObject` — no member of `PublicWeenieFlags` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs`) is named at `0x01000000`, and it is unclear whether `ApproachVendor`'s wire shape even carries this flag for a player-owned pack item the way `PublicWeenieDesc` does natively. | `src/AcDream.Core/Items/VendorSellAcceptability.cs` (`Evaluate`) | The type-mask and value-bounds checks already reject most illegitimate sells; the server remains authoritative regardless — a client-side accept here is only a UX/cursor gate, not a wire-safety concern, since ACE independently validates any real Sell request. | An item explicitly marked non-sellable via this specific bit (if any real item ever authors it) would still show the accept cursor and stage on drop if its type otherwise matches the vendor's merchandise mask; the actual sale attempt's server-side fate is untraced (ACE may or may not enforce this bit either). | `VendorProfile::InqAcceptability` `pc:484768-484797`/`0x005d1a90`, bitfield test at `pc:005d1aa7`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` §Q4 | +| AP-165 | **Filed 2026-08-09, Slice 6b (buy staging).** Retail's "Buying" tab `Buy Item`/`Clear Item` removal-amount rule (`gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`) tests the selected item's own `pwd._maxStackSize` (the item TYPE's stack ceiling) to decide `RemoveProfileFromList`'s amount argument (`-1` full removal vs `1` partial decrement). `VendorShopItem` does not carry a `MaxStackSize` wire field (only `DescStackSize`, the item's CURRENT authored per-stack quantity, already threaded through for pricing), so `VendorUiController.BuyStagingRemovalAmount` substitutes `DescStackSize` as the stackability test instead. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuyStagingRemovalAmount`) | The two fields agree for every case that matters in practice — `DescStackSize <= 1` implies `MaxStackSize <= 1` — and can only disagree for a vendor stocking a single unit of an otherwise-stackable item TYPE. | In that narrow case, `Buy Item`/`Clear Item` would decrement the staged quantity by one instead of clearing the whole entry outright — a minor UI residue (a stale partial-quantity row stays staged) with no wire or money-safety consequence, since a subsequent Buy Item/Clear Item click on the same row converges it to zero anyway. | `gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` §Q3 | +| AP-166 | **Filed 2026-08-09, Slice 6b/6c (staging presentation).** Two cosmetic gaps in the Buying/Selling tabs: (1) their own per-row count/price text and purse-total text (`m_buyListText`/`m_buyPurseText`, D0 ids `0x100000C7`/`C8`; `m_sellListText`/`m_sellPurseText`, `0x100000D0`/`D1`) are not wired — a staged row renders as an icon cell only (matching the Items tab's own icon-strip shape), with no adjacent per-row label; (2) a successful Sell Item/Sell All/Clear Item does not port retail's cross-panel `gmVendorUI::VendorItemSetSellState` — the player's OWN inventory panel highlight that marks an item as "pending sell" is neither shown on stage nor cleared on unstage. | `src/AcDream.App/UI/Layout/VendorUiController.cs` | The underlying staging/transaction mechanics (add, remove, batched Buy All/Sell All, `InqAcceptability` gating) are fully functional without these presentation details; the Items tab's shared name/cost text pair already shows price/quantity for whichever row is currently selected (including a staged one), and the shared system-message channel already surfaces rejection feedback. | A player cannot see a NON-selected staged row's price/quantity at a glance (only the currently-selected row's name/cost renders, via the Items tab's shared text pair — there is no per-row label on the Buying/Selling strips themselves), and a pending-sell item shows no visual cue back in the main inventory panel while staged. | `VendorBuyUI::VendorBuyUI` `pc:199717`; `VendorSellUI::VendorSellUI` `pc:199753` (purse/list text element construction); `gmVendorUI::VendorItemSetSellState` (call sites `pc:204107`/`204133`); `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4 D0 tree | | ~~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 137a2efb..79d60dda 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -375,6 +375,22 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return false; activeSession.SendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId); return true; + }, + // Slice 6b: "Buy All" — one batched 0x005F for every staged entry. + sendBuyAll: (vendorGuid, items, alternateCurrencyId) => + { + if (session.CurrentSession is not { } activeSession || !session.IsInWorld) + return false; + activeSession.SendBuy(vendorGuid, items, alternateCurrencyId); + return true; + }, + // Slice 6c: Sell (0x0060) — both "Sell Item" and "Sell All" reuse this. + sendSell: (vendorGuid, items) => + { + if (session.CurrentSession is not { } activeSession || !session.IsInWorld) + return false; + activeSession.SendSell(vendorGuid, items); + return true; }); } @@ -736,7 +752,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Inventory.Vendor, iconComposer.GetIcon, itemInteraction, - d.Actions.Selection), + d.Actions.Selection, + text => d.Communication.Chat.OnSystemMessage(text, 0x1Au)), Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), Confirmations: new ConfirmationRuntimeBindings( (type, context, accepted) => diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index eb7ee866..4e6f9935 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -219,6 +219,24 @@ internal sealed class SelectionInteractionController ItemUseRequestReservation? reservation) { CancelPendingApproach(); + // ItemHolder::UseObject @ 0x00588A80 has no distance/range check — + // retail's client sends Use unconditionally regardless of range; the + // walk-in is entirely server-driven (ACE's CreateMoveToChain, + // Player_Move.cs:37-65) and arrives back as an ordinary broadcast + // motion command (Q2, docs/research/2026-08-08-slice6b-vendor- + // completion-research.md). This mirrors SendPickup's !IsCloseRange + // branch below: kick off the SAME local client-predicted + // MoveToObject animation for immediate visual feel, but never gate + // the wire send on its arrival — unlike Pickup's close-range + // TurnToObject branch, Use keeps sending immediately either way (the + // existing RuntimeInteractionTransactionState.TryDispatchUse doc + // comment: "consume the strict 0.2-second gate, send immediately"). + if (_query.TryGetApproach(serverGuid, out InteractionApproach approach) + && !approach.IsCloseRange) + { + _movement.BeginApproach(approach); + } + bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 175d0f73..64eca190 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -67,6 +67,11 @@ public sealed class ItemInteractionController : IDisposable // in world) — the bool return is what lets TryBuy release the // reservation instead of leaking BusyCount forever. private readonly Func? _sendBuy; + // Slice 6b/6c: vendorGuid, staged (amount, itemGuid) entries[, alternate + // currency] -> true when the wire send actually happened. Same + // dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell. + private readonly Func, uint, bool>? _sendBuyAll; + private readonly Func, bool>? _sendSell; private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; @@ -108,7 +113,9 @@ public sealed class ItemInteractionController : IDisposable CombatState? combatState = null, Action? sendChangeCombatMode = null, Action? requestUse = null, - Func? sendBuy = null) + Func? sendBuy = null, + Func, uint, bool>? sendBuyAll = null, + Func, bool>? sendSell = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -139,6 +146,8 @@ public sealed class ItemInteractionController : IDisposable _systemMessage = systemMessage; _requestUse = requestUse; _sendBuy = sendBuy; + _sendBuyAll = sendBuyAll; + _sendSell = sendSell; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); _runtimeTransactions = runtimeTransactions @@ -289,6 +298,90 @@ public sealed class ItemInteractionController : IDisposable return true; } + /// + /// Slice 6b: retail's "Buy All" button (gmVendorUI::HandleButtonClicks + /// case 0x100000ca, pc:204011-204079) — the ONE path that + /// sends a multi-item Buy in a single wire call. Rides the SAME + /// one-request-at-a-time use reservation does. + /// Retail's client-side affordability/pack-capacity pre-checks + /// (pc:204017/204032/204053/204067) are deliberately NOT ported + /// here either, for the same reason 's own doc + /// comment already gives for the single-item Buy: the server is + /// authoritative either way (register AP-162, extended to this batched + /// path rather than filing a second near-duplicate row). + /// + public bool TryBuyAll( + uint vendorGuid, + IReadOnlyList<(int Amount, uint ItemGuid)> items, + uint alternateCurrencyId) + { + if (vendorGuid == 0u || items is null || items.Count == 0 || _sendBuyAll is null) + return false; + if (!EnsureInventoryRequestReady()) + return false; + + ItemUseRequestReservation reservation = BeginUseRequestReservation(); + bool dispatched; + try + { + dispatched = _sendBuyAll(vendorGuid, items, alternateCurrencyId); + } + catch + { + reservation.CancelBeforeDispatch(); + throw; + } + + if (!dispatched) + { + reservation.CancelBeforeDispatch(); + return false; + } + + reservation.MarkDispatched(); + return true; + } + + /// + /// Slice 6c: retail's Sell path — both "Sell Item" + /// (gmVendorUI::HandleButtonClicks case 0x100000d2, + /// pc:204101-204112, a one-entry list) and "Sell All" (case + /// 0x100000d3, pc:204113-204129, an n-entry list) reuse + /// this single method — retail's own CM_Vendor::Event_Sell has no + /// separate single-item opcode the way Buy does. Same reservation dance + /// as /. + /// + public bool TrySell( + uint vendorGuid, + IReadOnlyList<(int Amount, uint ItemGuid)> items) + { + if (vendorGuid == 0u || items is null || items.Count == 0 || _sendSell is null) + return false; + if (!EnsureInventoryRequestReady()) + return false; + + ItemUseRequestReservation reservation = BeginUseRequestReservation(); + bool dispatched; + try + { + dispatched = _sendSell(vendorGuid, items); + } + catch + { + reservation.CancelBeforeDispatch(); + throw; + } + + if (!dispatched) + { + reservation.CancelBeforeDispatch(); + return false; + } + + 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/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 47cb73c9..e909b159 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -60,7 +60,7 @@ namespace AcDream.App.UI.Layout; /// from a direct field write here. /// /// -public sealed class VendorUiController : IRetainedPanelController +public sealed class VendorUiController : IRetainedPanelController, IItemListDragHandler { public const uint LayoutId = 0x21000012u; public const uint RootId = 0x100000B7u; @@ -98,6 +98,22 @@ public sealed class VendorUiController : IRetainedPanelController public const uint SellingListId = 0x100000CEu; public const uint SellingScrollbarId = 0x100000CFu; + // Slice 6b: the "Buying" tab's staging-review buttons + // (docs/research/2026-08-08-slice5-vendor-browse-research.md §B.4 D0 + // tree). All four are optional (nullable) the same way BuyButtonId/ + // AddButtonId are — a missing authored control degrades gracefully + // instead of failing Bind. + public const uint BuyItemButtonId = 0x100000C9u; + public const uint BuyAllButtonId = 0x100000CAu; + public const uint BuyClearItemButtonId = 0x100000CBu; + public const uint BuyClearListButtonId = 0x100000CCu; + + // Slice 6c: the "Selling" tab's staging-review buttons — same D0 tree. + public const uint SellItemButtonId = 0x100000D2u; + public const uint SellAllButtonId = 0x100000D3u; + public const uint SellClearItemButtonId = 0x100000D4u; + public const uint SellClearListButtonId = 0x100000D5u; + /// /// F1 (Slice 5.4 review): the category dropdown's authored popup. /// Retail UIElement_Menu::MakePopup (0x0046D310, @@ -320,14 +336,33 @@ public sealed class VendorUiController : IRetainedPanelController private readonly UiButton? _close; private readonly UiButton? _buyButton; private readonly UiButton? _addButton; + // Slice 6b: "Buying" tab staging-review buttons. + private readonly UiButton? _buyItemButton; + private readonly UiButton? _buyAllButton; + private readonly UiButton? _buyClearItemButton; + private readonly UiButton? _buyClearListButton; + // Slice 6c: "Selling" tab staging-review buttons. + private readonly UiButton? _sellItemButton; + private readonly UiButton? _sellAllButton; + private readonly UiButton? _sellClearItemButton; + private readonly UiButton? _sellClearListButton; + // Slice 6b/6c: retail's m_buyList/m_sellList — see VendorStagingList's doc comment. + private readonly VendorStagingList _buyStaging = new(); + private readonly VendorStagingList _sellStaging = new(); + private readonly RetailDialogFactory? _dialogs; + private readonly Action? _systemMessage; private readonly List<(string Label, ItemType Mask)> _presentCategories = new(); private int _selectedCategoryIndex = -1; - // 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). + // Slice 6.3/6b: tracks "does the CURRENT selection permit Buy/Add" + // separately from "is a Buy request currently in flight" + // (RecomputeBuyButtonEnabled combines both for the Buy AND Add buttons — + // Add to List is now wired (Slice 6b), no longer permanently disabled). private bool _buyEnabledBySelection; + // Slice 6b: gmVendorUI::m_curDialogContext — a nonzero value means the + // X-close confirmation is already up; HandleButtonClicks' 0x100000d6 + // case only opens a NEW one when this is 0 (pc:204155). + private uint _closeConfirmContext; private bool _disposed; private VendorUiController( @@ -357,6 +392,16 @@ public sealed class VendorUiController : IRetainedPanelController UiButton? close, UiButton? buyButton, UiButton? addButton, + UiButton? buyItemButton, + UiButton? buyAllButton, + UiButton? buyClearItemButton, + UiButton? buyClearListButton, + UiButton? sellItemButton, + UiButton? sellAllButton, + UiButton? sellClearItemButton, + UiButton? sellClearListButton, + RetailDialogFactory? dialogs, + Action? systemMessage, UiDatFont? datFont, BitmapFont? debugFont, Func resolveSprite, @@ -387,6 +432,16 @@ public sealed class VendorUiController : IRetainedPanelController _close = close; _buyButton = buyButton; _addButton = addButton; + _buyItemButton = buyItemButton; + _buyAllButton = buyAllButton; + _buyClearItemButton = buyClearItemButton; + _buyClearListButton = buyClearListButton; + _sellItemButton = sellItemButton; + _sellAllButton = sellAllButton; + _sellClearItemButton = sellClearItemButton; + _sellClearListButton = sellClearListButton; + _dialogs = dialogs; + _systemMessage = systemMessage; _itemList.Columns = 1; _itemList.SingleRow = true; @@ -440,6 +495,11 @@ public sealed class VendorUiController : IRetainedPanelController sellingScrollbar.Model = _sellingList.Scroll; sellingScrollbar.Horizontal = true; } + // Slice 6c: gmVendorUI::HandleDropRelease routes EVERY drop in the + // whole panel through one IsAncestorOfMe(target, m_sellShopList) + // gate (pc:204229-204246) — the Selling tab's list is the ONLY drop + // target. UiItemList.RegisterDragHandler is the structural analogue. + _sellingList?.RegisterDragHandler(this); // F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers // (UiMenu draws nothing without SpriteResolve — see the popup @@ -502,6 +562,31 @@ public sealed class VendorUiController : IRetainedPanelController // staging list required (research doc §B.1). if (_buyButton is not null) _buyButton.OnClick = BuySelectedItem; + // Slice 6b: 0x100000c3 — VendorItemsUI::AddToBuyList (research doc + // §Q3). No longer permanently disabled (AP-161 F8 residual closes). + if (_addButton is not null) + _addButton.OnClick = AddSelectedToBuyList; + // Slice 6b: the "Buying" tab's four staging buttons. + if (_buyItemButton is not null) + _buyItemButton.OnClick = BuyItemButtonPressed; + if (_buyAllButton is not null) + _buyAllButton.OnClick = BuyAllButtonPressed; + if (_buyClearItemButton is not null) + _buyClearItemButton.OnClick = BuyClearItemButtonPressed; + if (_buyClearListButton is not null) + _buyClearListButton.OnClick = () => _buyStaging.Clear(); + // Slice 6c: the "Selling" tab's four staging buttons. + if (_sellItemButton is not null) + _sellItemButton.OnClick = SellItemButtonPressed; + if (_sellAllButton is not null) + _sellAllButton.OnClick = SellAllButtonPressed; + if (_sellClearItemButton is not null) + _sellClearItemButton.OnClick = SellClearItemButtonPressed; + if (_sellClearListButton is not null) + _sellClearListButton.OnClick = () => _sellStaging.Clear(); + + _buyStaging.Changed += RebuildBuyingList; + _sellStaging.Changed += RebuildSellingList; ShowTab(VendorPanelTab.Items); ClearContent(); @@ -595,7 +680,15 @@ public sealed class VendorUiController : IRetainedPanelController Func resolveSprite, uint emptySlotSprite = 0u, uint buyingEmptySlotSprite = 0u, - uint sellingEmptySlotSprite = 0u) + uint sellingEmptySlotSprite = 0u, + // Slice 6b: the panel's own X-close confirmation when staging is + // non-empty (Q3's close-button finding). Optional — absent gracefully + // degrades the close gate to a plain hide (see CloseButtonPressed). + RetailDialogFactory? dialogs = null, + // Slice 6b/6c: InqAcceptability rejection strings + retail's two + // Buy-All affordability/capacity transient errors (deliberately not + // ported, register AP-162) share this sink. + Action? systemMessage = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(vendor); @@ -632,6 +725,15 @@ public sealed class VendorUiController : IRetainedPanelController UiScrollbar? buyingScrollbar = layout.FindElement(BuyingScrollbarId) as UiScrollbar; UiItemList? sellingList = layout.FindElement(SellingListId) as UiItemList; UiScrollbar? sellingScrollbar = layout.FindElement(SellingScrollbarId) as UiScrollbar; + // Slice 6b/6c: the eight staging-review buttons — all optional. + UiButton? buyItemButton = layout.FindElement(BuyItemButtonId) as UiButton; + UiButton? buyAllButton = layout.FindElement(BuyAllButtonId) as UiButton; + UiButton? buyClearItemButton = layout.FindElement(BuyClearItemButtonId) as UiButton; + UiButton? buyClearListButton = layout.FindElement(BuyClearListButtonId) as UiButton; + UiButton? sellItemButton = layout.FindElement(SellItemButtonId) as UiButton; + UiButton? sellAllButton = layout.FindElement(SellAllButtonId) as UiButton; + UiButton? sellClearItemButton = layout.FindElement(SellClearItemButtonId) as UiButton; + UiButton? sellClearListButton = layout.FindElement(SellClearListButtonId) as UiButton; return new VendorUiController( vendor, @@ -660,6 +762,16 @@ public sealed class VendorUiController : IRetainedPanelController close, buyButton, addButton, + buyItemButton, + buyAllButton, + buyClearItemButton, + buyClearListButton, + sellItemButton, + sellAllButton, + sellClearItemButton, + sellClearListButton, + dialogs, + systemMessage, datFont, debugFont, resolveSprite, @@ -695,6 +807,11 @@ public sealed class VendorUiController : IRetainedPanelController // resetting here is sufficient — RebuildCategories' existing // clamp (selected<0 -> 0) then lands on the new vendor's // first present category, matching retail. + // Slice 6b/6c: a different vendor's staged items are for a + // shop the player is no longer looking at — clear both + // staging lists the same way the category selection resets. + _buyStaging.Clear(); + _sellStaging.Clear(); _selectedCategoryIndex = -1; ShowTab(VendorPanelTab.Items); RebuildCategories(); @@ -703,13 +820,24 @@ public sealed class VendorUiController : IRetainedPanelController case VendorStateTransitionKind.Refreshed: // Same vendor re-approached (post-buy/sell refresh, Slice 6) // — preserve the selection via RebuildCategories' clamp, - // matching retail's sameVendor==1 path. + // matching retail's sameVendor==1 path. Staging is NOT + // cleared here: a Refreshed transition follows a Buy All/ + // Sell All send, which already flushed its own list + // synchronously at send time (retail: PackableList::Flush + // right after SendShopEvent, pc:204076/label_4c560a) — by + // the time this fires the relevant list is already empty in + // the normal flow, and the OTHER (untouched) list must + // survive a refresh triggered by its sibling. ShowTab(VendorPanelTab.Items); RebuildCategories(); _window.Show(); break; case VendorStateTransitionKind.Closed: case VendorStateTransitionKind.Reset: + // Slice 6b/6c: session close/teardown clears staging WITH + // the session (contract's C2/C3 close semantics). + _buyStaging.Clear(); + _sellStaging.Clear(); ClearContent(); ShowTab(VendorPanelTab.Items); _window.Hide(); @@ -1128,39 +1256,37 @@ public sealed class VendorUiController : IRetainedPanelController } /// - /// F8 (Slice 6 review): "Add to List" (staging into the "Buying" tab, - /// contract decision 6) has NO wired OnClick at all — before this - /// fix it enabled on selection exactly like Buy, so it read as a working - /// affordance that silently did nothing on click, which is worse than a - /// disabled button (a disabled Add correctly signals "not available - /// yet"; an enabled dead Add signals a bug). Permanently disabled until - /// the "Buying" tab's staging list is actually implemented — see the - /// register, AP-161. + /// Slice 6b (AP-161 F8 residual closes): "Add to List" now enables with + /// selection exactly like Buy — the "Buying" tab's staging list is + /// implemented, so an enabled Add is no longer a dead affordance. /// private void SetActionButtonsEnabled(bool enabled) { _buyEnabledBySelection = enabled; - if (_addButton is not null) _addButton.Enabled = false; 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) is - /// PERMANENTLY disabled instead (F8, Slice 6 review) — see - /// . 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. + /// Slice 6.3/6b: the Buy AND Add buttons' enabled state is the + /// CONJUNCTION of "is something selected" (, + /// set by ) and "is the shared + /// inventory/use gate free right now" + /// (). + /// Add is selection-gated only (retail's AddToBuyList sends + /// nothing to the server — no busy-gate reason to disable it while a + /// Buy/Sell is in flight), but sharing this recompute keeps both buttons + /// consistent with a single call site. Called on every selection change + /// AND on every + /// tick, so Buy 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; + if (_addButton is not null) + _addButton.Enabled = _buyEnabledBySelection; } private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled(); @@ -1203,33 +1329,398 @@ public sealed class VendorUiController : IRetainedPanelController _vendor.Profile.AlternateCurrencyWcid); } + private bool TryFindShopItem(uint guid, out VendorShopItem shopItem) + { + foreach (VendorShopItem item in _vendor.Items) + { + if (item.ItemGuid == guid) + { + shopItem = item; + return true; + } + } + shopItem = default; + return false; + } + /// - /// G4 (vendor gate finding): port of retail's close/pushpin button - /// handler — gmVendorUI::HandleButtonClicks's 0x100000d6 - /// case (pc:204147-204182). Retail branches on whether the + /// Slice 6b: Items-tab "Add to List" — gmVendorUI::HandleButtonClicks + /// case 0x100000c3 (pc:203970-203988). Stages the + /// globally-selected shop item at — the + /// SAME quantity computation the display and Buy button already share + /// (F2) — into the "Buying" tab's list. Sends NOTHING to the server + /// (VendorItemsUI::AddToBuyList is purely client-local). + /// + private void AddSelectedToBuyList() + { + if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem)) + return; + + uint quantity = ResolveBuyQuantity(shopItem); + _buyStaging.Add(shopItem.ItemGuid, (int)quantity); + } + + /// + /// Retail's Buy Item/Clear Item (Buying tab) shared + /// removal-amount rule (pc:203996/204086): stackable -> -1 + /// (full removal), else 1. Retail tests the item's own + /// pwd._maxStackSize (the item TYPE's stack ceiling) — a field + /// does not carry on the wire today. This + /// substitutes (the item's + /// CURRENT authored stack depth, already threaded through for pricing) + /// as the stackability test instead; the two agree for every case that + /// matters in practice (DescStackSize <= 1 implies + /// MaxStackSize <= 1) and disagree only for a vendor stocking a + /// single unit of an otherwise-stackable item TYPE, where the worst case + /// is a staged entry decrementing by one instead of clearing outright — + /// a minor UI residue, not a money/wire-safety issue. See the register. + /// + private static int BuyStagingRemovalAmount(VendorShopItem item) => + (item.DescStackSize ?? 1) > 1 ? -1 : 1; + + /// + /// Slice 6b: "Buying" tab's "Buy Item" — retail case 0x100000c9 + /// (pc:203989-204010). Reuses the SAME immediate single-item + /// BuySingleItem path () + /// the Items tab's own Buy button uses — reads the GLOBAL slider + /// quantity, not the staged entry's own quantity — then on a successful + /// dispatch removes the staged entry (). + /// + private void BuyItemButtonPressed() + { + if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem)) + return; + + uint quantity = ResolveBuyQuantity(shopItem); + if (_itemInteraction.TryBuy( + _vendor.VendorId, + shopItem.ItemGuid, + (int)quantity, + _vendor.Profile.AlternateCurrencyWcid)) + { + _buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem)); + } + } + + /// + /// Slice 6b: "Buying" tab's "Buy All" — retail case 0x100000ca + /// (pc:204011-204079). Sends every staged entry as ONE batched + /// Buy (). Retail's + /// client-side affordability/pack-capacity pre-checks + /// (pc:204017/204032/204053/204067) are deliberately NOT ported — + /// see TryBuyAll's own doc comment (register AP-162, extended + /// rather than duplicated). On a successful DISPATCH the whole staged + /// list is flushed UNCONDITIONALLY and immediately, matching retail's + /// literal order: SendShopEvent(...) then + /// PackableList::Flush(&m_buyList) (pc:204075-204076) — + /// the clear happens right after the send, not gated on the eventual + /// server response/UseDone. + /// + private void BuyAllButtonPressed() + { + if (_buyStaging.IsEmpty) + return; + + var items = new List<(int Amount, uint ItemGuid)>(_buyStaging.Entries.Count); + foreach (VendorStagingEntry entry in _buyStaging.Entries) + items.Add((entry.Quantity, entry.ItemGuid)); + + if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid)) + _buyStaging.Clear(); + } + + /// + /// Slice 6b: "Buying" tab's "Clear Item" — retail case 0x100000cb + /// (pc:204080-204094). The EXACT SAME removal call "Buy Item" + /// makes (label_4c52ea) but WITHOUT buying first — pure removal, + /// no transaction. + /// + private void BuyClearItemButtonPressed() + { + if (_selection.SelectedObjectId is not { } guid) + return; + + int amount = TryFindShopItem(guid, out VendorShopItem shopItem) + ? BuyStagingRemovalAmount(shopItem) + : -1; + _buyStaging.Remove(guid, amount); + } + + /// + /// Slice 6c: "Selling" tab's "Sell Item" — retail case 0x100000d2 + /// (pc:204101-204112). Sells the globally-selected staged item + /// immediately (a one-entry + /// list using the entry's OWN staged quantity — Sell has no separate + /// global-slider read the way Buy does), then on a successful dispatch + /// removes the ENTIRE staged entry unconditionally — retail always + /// passes 0xffffffff here (pc:204108), unlike the Buy + /// side's stackable-conditional amount. + /// + private void SellItemButtonPressed() + { + if (_selection.SelectedObjectId is not { } guid + || !_sellStaging.TryGet(guid, out VendorStagingEntry entry)) + { + return; + } + + if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) })) + _sellStaging.Remove(guid, -1); + } + + /// + /// Slice 6c: "Selling" tab's "Sell All" — retail case 0x100000d3 + /// (pc:204113-204129). Sends every staged entry as ONE batched + /// Sell. On a successful dispatch the whole staged list is flushed + /// unconditionally and immediately (the shared tail label_4c560a + /// also reached by "Clear List" — FlushSellListSellState + + /// PackableList::Flush(&m_sellList)), the same optimistic + /// clear-right-after-send shape as . + /// + private void SellAllButtonPressed() + { + if (_sellStaging.IsEmpty) + return; + + var items = new List<(int Amount, uint ItemGuid)>(_sellStaging.Entries.Count); + foreach (VendorStagingEntry entry in _sellStaging.Entries) + items.Add((entry.Quantity, entry.ItemGuid)); + + if (_itemInteraction.TrySell(_vendor.VendorId, items)) + _sellStaging.Clear(); + } + + /// + /// Slice 6c: "Selling" tab's "Clear Item" — retail case 0x100000d4 + /// (pc:204131-204137): always a full removal (0xffffffff), + /// no transaction. Retail also clears the item's "pending sell" + /// highlight in the player's own inventory panel + /// (VendorItemSetSellState) — that cross-panel highlight is a + /// deliberately unported cosmetic (see the register). + /// + private void SellClearItemButtonPressed() + { + if (_selection.SelectedObjectId is not { } guid) + return; + _sellStaging.Remove(guid, -1); + } + + /// + /// Renders into the "Buying" tab's own item + /// strip — icon cells identical in shape to the Items list's own (the + /// SAME 0x1000033A cell-template prototype, per the G2 vendor-gate + /// finding), selectable so / + /// can read the globally-selected + /// guid. A staged guid that has left 's current shop + /// list (a rare race, e.g. mid-refresh) is skipped rather than crashing. + /// + private void RebuildBuyingList() + { + if (_buyingList is not { } list) + return; + + uint? selectedGuid = _selection.SelectedObjectId; + using (list.DeferLayout()) + { + list.Flush(); + foreach (VendorStagingEntry entry in _buyStaging.Entries) + { + if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem shopItem)) + continue; + + uint icon = _resolveIcon( + (ItemType)(shopItem.ItemType ?? 0u), + shopItem.IconId, + shopItem.IconUnderlayId, + shopItem.IconOverlayId, + shopItem.Effects); + var cell = new UiItemSlot + { + SpriteResolve = list.SpriteResolve, + SlotIndex = list.GetNumUIItems(), + AllowDragSource = false, + }; + cell.SetItem(shopItem.ItemGuid, icon); + cell.Selected = shopItem.ItemGuid == selectedGuid; + VendorShopItem captured = shopItem; + cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + list.AddItem(cell); + } + } + } + + /// + /// Renders into the "Selling" tab's own item + /// strip. Unlike , a staged sell entry's + /// icon/type data comes from (the PLAYER's own + /// pack item), not 's shop list — the item was + /// dragged FROM the player's inventory, never authored as vendor stock. + /// + private void RebuildSellingList() + { + if (_sellingList is not { } list) + return; + + uint? selectedGuid = _selection.SelectedObjectId; + using (list.DeferLayout()) + { + list.Flush(); + foreach (VendorStagingEntry entry in _sellStaging.Entries) + { + if (_objects.Get(entry.ItemGuid) is not { } item) + continue; + + uint icon = _resolveIcon( + item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); + var cell = new UiItemSlot + { + SpriteResolve = list.SpriteResolve, + SlotIndex = list.GetNumUIItems(), + AllowDragSource = false, + }; + cell.SetItem(item.ObjectId, icon); + cell.Selected = item.ObjectId == selectedGuid; + uint captured = item.ObjectId; + cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor); + list.AddItem(cell); + } + } + } + + // ── IItemListDragHandler (Slice 6c: the Selling tab's list is the SOLE + // drop target — gmVendorUI::HandleDropRelease routes every drag release + // anywhere in the panel through one IsAncestorOfMe(target, m_sellShopList) + // gate, pc:204229-204246) ────────────────────────────────────────────── + + /// + /// The Selling list never sources a drag of its own — every staged cell + /// sets AllowDragSource = false (F3, Slice 6 review), the same + /// non-drag-source convention every vendor row uses — so + /// 's drag-lift dispatch (which routes to the + /// SOURCE list's own registered handler) can never actually reach this + /// method in practice. Implemented as a no-op for interface completeness. + /// + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + } + + public ItemDragAcceptance OnDragOver( + UiItemList targetList, + UiItemSlot targetCell, + ItemDragPayload payload) + { + if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u) + return ItemDragAcceptance.Reject; + + return EvaluateSellAcceptability(payload.ObjId, out _) == VendorSellRejection.None + ? ItemDragAcceptance.Accept + : ItemDragAcceptance.Reject; + } + + /// + /// Port of VendorSellUI::AcceptDragObject (pc:203866-203905, + /// the non-silent release path that calls DragItemAcceptable with + /// silent=0, showing a rejection string) chained into + /// VendorSellUI::AddItemToSell (pc:203546-203567) on + /// success: auto-switch to the "Selling" tab, globally select the + /// dropped item, stage it. Purely client-local — sends nothing to the + /// server, matching the Buying tab's "Add to List". + /// + public void HandleDropRelease( + UiItemList targetList, + UiItemSlot targetCell, + ItemDragPayload payload) + { + if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u) + return; + + VendorSellRejection rejection = EvaluateSellAcceptability(payload.ObjId, out int quantity); + if (rejection != VendorSellRejection.None) + { + if (VendorSellAcceptability.MessageFor(rejection) is { } message) + _systemMessage?.Invoke(message); + return; + } + + ShowTab(VendorPanelTab.Selling); + _selection.Select(payload.ObjId, SelectionChangeSource.Vendor); + _sellStaging.Add(payload.ObjId, quantity); + } + + /// + /// Shared drag-over/drop gate — + /// fed from the dragged item's own state and + /// the open vendor's merchandise terms. + /// is the staged quantity a successful drop + /// would use: 1 for a non-stack item, else the LIVE split-quantity + /// slider value for the dragged item — the SAME read + /// ExternalContainerController.HandleDropRelease already uses + /// (StackSplitQuantityState.GetObjectSplitSize only returns the + /// slider value for the CURRENTLY selected item; both + /// InventoryController and PaperdollController already + /// select the dragged item on LIFT — ItemList_BeginDrag @ + /// 0x004E32D0 — so by the time a drop reaches here the dragged item + /// is already the global selection in the realistic flow). The Slice + /// 6b/6c research doc's Q4 section flags this exact quantity source as + /// an inferred analogy to the Buying tab's AddToBuyList, not a + /// byte-verified citation for the Selling side specifically — see + /// VendorStagingList.Add's own doc comment. + /// + private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) + { + quantity = 1; + if (_objects.Get(itemGuid) is not { } item) + return VendorSellRejection.WrongType; + + bool ownedByPlayer = _itemInteraction.IsOwnedByPlayer(itemGuid); + int containedItemCount = _objects.GetContents(itemGuid).Count; + int perUnitValue = VendorPricing.PerUnitValue(item.Value, item.StackSize); + + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer, + containedItemCount, + (uint)item.Type, + perUnitValue, + _vendor.Profile.MerchandiseItemTypes, + _vendor.Profile.MerchandiseMinValue, + _vendor.Profile.MerchandiseMaxValue); + + if (rejection == VendorSellRejection.None) + { + uint fullStack = (uint)Math.Max(1, item.StackSize); + quantity = fullStack > 1 + ? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack) + : 1; + } + return rejection; + } + + /// + /// G4/Slice 6b: port of retail's close/pushpin button handler — + /// gmVendorUI::HandleButtonClicks's 0x100000d6 case + /// (pc:204147-204182). Retail branches on whether the /// Buying/Selling staging lists (m_buyList/m_sellList) hold /// anything uncommitted: with nothing pending it calls ONLY /// this->vtable->SetVisible(0) — a plain window hide, NOT /// gmVendorUI::CloseVendor (pc:202080, the range-watcher- /// unregister/session-teardown function - /// ports). Only when something IS pending does retail show a - /// confirmation dialog ("You have not completed all transactions...") - /// whose Yes callback (gmVendorUI::CloseVendorDialogCallback, - /// pc:202104-202166) is what actually reaches - /// CM_Vendor::SendNotice_CloseVendor — itself an internal - /// notice-bus fanout to local UI listeners, not a network send (see the - /// class doc's A.4 citation: retail's close path never puts anything on - /// the wire either way). - /// - /// This controller's staging lists are ALWAYS empty (Slice 6 territory — - /// the "Buying"/"Selling" tabs render but are never populated, see the - /// class doc's "Three tabs, not two" note), so retail's - /// m_buyList.head == 0 && m_sellList.head == 0 condition - /// is vacuously true for every close today — the confirmation-dialog - /// branch has no reachable case yet and is deliberately not ported; - /// revisit once staging lands. - /// - /// + /// ports). Only when something IS pending, and only when no confirmation + /// is already up (gmVendorUI::m_curDialogContext == 0, + /// pc:204155), does retail show the confirmation dialog ("You have + /// not completed all transactions. Are you sure you want to leave this + /// vendor" — the exact string, read from the decompiled binary's data + /// segment at 0x007b5bd8, resolving the Slice 6b/6c research doc's + /// truncated "…" citation). Its Yes callback + /// (gmVendorUI::CloseVendorDialogCallback, pc:202104-202166) + /// reaches CM_Vendor::SendNotice_CloseVendor — an internal + /// notice-bus fanout, not a network send (class doc's A.4 citation) — + /// and nothing in that call chain touches m_buyList/m_sellList, + /// so staging survives a "Yes, leave anyway" exactly like retail: the + /// window hides, the staged items are still there next time the vendor + /// is reopened. A "No" (or dismissing the dialog) leaves the window open + /// with staging untouched. + /// + /// /// Behavior change from the prior port. This button used to call /// directly — a full session teardown /// (VendorId/Profile/Items cleared, every materialized shop item @@ -1245,9 +1736,32 @@ public sealed class VendorUiController : IRetainedPanelController /// refresh-in-place path (, /// which preserves the player's category selection) instead of a full /// from-scratch reopen. - /// - /// - private void CloseButtonPressed() => _window.Hide(); + /// + private const string CloseConfirmationMessage = + "You have not completed all transactions. Are you sure you want to leave this vendor"; + + private void CloseButtonPressed() + { + if (_buyStaging.IsEmpty && _sellStaging.IsEmpty) + { + _window.Hide(); + return; + } + + // No dialog infra wired (a minimal test harness), or a confirmation + // is already up — retail's m_curDialogContext==0 gate (pc:204155). + if (_dialogs is null || _closeConfirmContext != 0u) + return; + + _closeConfirmContext = _dialogs.MakeDialog( + RetailDialogData.Confirmation(CloseConfirmationMessage), + result => + { + _closeConfirmContext = 0u; + if (result.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _window.Hide(); + }); + } private void ClearContent() { @@ -1315,6 +1829,13 @@ public sealed class VendorUiController : IRetainedPanelController _objects.ObjectRemoved -= OnObjectRemoved; _itemInteraction.StateChanged -= OnInteractionStateChanged; _splitQuantity.Changed -= OnSplitQuantityChanged; + _buyStaging.Changed -= RebuildBuyingList; + _sellStaging.Changed -= RebuildSellingList; + if (_closeConfirmContext != 0u) + { + _dialogs?.CloseDialog(_closeConfirmContext); + _closeConfirmContext = 0u; + } RetailTabBinding.SetClick(_itemsTab, null); RetailTabBinding.SetClick(_buyingTab, null); RetailTabBinding.SetClick(_sellingTab, null); @@ -1325,5 +1846,23 @@ public sealed class VendorUiController : IRetainedPanelController _close.OnClick = null; if (_buyButton is not null) _buyButton.OnClick = null; + if (_addButton is not null) + _addButton.OnClick = null; + if (_buyItemButton is not null) + _buyItemButton.OnClick = null; + if (_buyAllButton is not null) + _buyAllButton.OnClick = null; + if (_buyClearItemButton is not null) + _buyClearItemButton.OnClick = null; + if (_buyClearListButton is not null) + _buyClearListButton.OnClick = null; + if (_sellItemButton is not null) + _sellItemButton.OnClick = null; + if (_sellAllButton is not null) + _sellAllButton.OnClick = null; + if (_sellClearItemButton is not null) + _sellClearItemButton.OnClick = null; + if (_sellClearListButton is not null) + _sellClearListButton.OnClick = null; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index fa1ce966..d3248f54 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -182,7 +182,11 @@ public sealed record VendorRuntimeBindings( // 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); + SelectionState Selection, + // Slice 6b/6c: InqAcceptability's rejection strings, delivered on the + // SAME notice-0x1a system-message channel AppraisalRuntimeBindings' + // own DisplaySystemMessage already uses. + Action? DisplaySystemMessage = null); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -2029,7 +2033,11 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, emptySlotSprite, buyingEmptySlotSprite, - sellingEmptySlotSprite); + sellingEmptySlotSprite, + // Slice 6b/6c: the X-close staging confirmation and + // InqAcceptability rejection strings. + DialogFactory, + b.DisplaySystemMessage); if (VendorController is null) { Console.WriteLine("[M4] vendor: required authored controls are missing."); diff --git a/src/AcDream.Core.Net/Messages/VendorRequests.cs b/src/AcDream.Core.Net/Messages/VendorRequests.cs index 28d05b29..1cf8816a 100644 --- a/src/AcDream.Core.Net/Messages/VendorRequests.cs +++ b/src/AcDream.Core.Net/Messages/VendorRequests.cs @@ -5,9 +5,9 @@ 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. +/// Outbound vendor GameActions. Buy (0x005F, Slice 6.3) and Sell +/// (0x0060, Slice 6b/6c) — both a batched item list; see +/// for the Sell-specific wire shape. /// /// /// Wire layout, confirmed FOUR ways with zero disagreement (research doc @@ -55,6 +55,7 @@ public static class VendorRequests { public const uint GameActionEnvelope = 0xF7B1u; public const uint BuyOpcode = 0x005Fu; + public const uint SellOpcode = 0x0060u; /// /// Build a Buy GameAction for — retail's @@ -107,4 +108,47 @@ public static class VendorRequests vendorGuid, new[] { (amount, itemGuid) }, alternateCurrencyId); + + /// + /// Build a Sell GameAction for — retail's + /// 2-argument CM_Vendor::Event_Sell(vendorGuid, &list) + /// (pc:689229, 0x006AA000). Slice 6b/6c research doc §A.3/ + /// §Q4: unlike Buy, Sell's body never writes a trailing field — no + /// currency id, confirmed both by the retail decompiled sender and by + /// ACE's reader (GameActionSellItems.Handle reads only + /// vendorGuid, numItems, then per-item amount(i32)/ + /// objectGuid(u32)) and by Chorizite's/holtburger's independent + /// generated Vendor_Sell/SellActionData shapes, neither of + /// which carries an AlternateCurrencyId member at all. Used by + /// both the "Sell Item" (a one-entry list) and "Sell All" (n-entry list) + /// buttons — retail's own Event_Sell has no separate single-item + /// opcode, unlike Buy's asymmetric client-side "immediate single" vs + /// "batched all" naming. + /// + public static byte[] BuildSell( + uint gameActionSequence, + uint vendorGuid, + IReadOnlyList<(int Amount, uint ItemGuid)> items) + { + ArgumentNullException.ThrowIfNull(items); + + int itemCount = items.Count; + byte[] body = new byte[20 + (itemCount * 8)]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SellOpcode); + 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; + } + + return body; + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 1c4f8f0a..12bd341e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2406,6 +2406,33 @@ public sealed class WorldSession : IDisposable SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId)); } + /// + /// Slice 6b: send a batched retail Buy (0x005F) — the "Buy All" path, + /// one wire call for every staged entry. See + /// for the single-item convenience overload the "Items"/"Buying" tabs' + /// immediate Buy buttons keep using unchanged. + /// + public void SendBuy( + uint vendorGuid, + IReadOnlyList<(int Amount, uint ItemGuid)> items, + uint alternateCurrencyId) + { + uint seq = NextGameActionSequence(); + SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, items, alternateCurrencyId)); + } + + /// + /// Slice 6c: send retail Sell (0x0060) — . + /// Used by both the "Sell Item" (one-entry list) and "Sell All" (n-entry + /// list) buttons; Sell has no separate single-item opcode the way Buy + /// does. + /// + public void SendSell(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) + { + uint seq = NextGameActionSequence(); + SendGameAction(VendorRequests.BuildSell(seq, vendorGuid, items)); + } + /// Send retail IdentifyObject/Appraise (0x00C8). public void SendAppraise(uint targetGuid) { diff --git a/src/AcDream.Core/Items/VendorSellAcceptability.cs b/src/AcDream.Core/Items/VendorSellAcceptability.cs new file mode 100644 index 00000000..467d48a2 --- /dev/null +++ b/src/AcDream.Core/Items/VendorSellAcceptability.cs @@ -0,0 +1,137 @@ +namespace AcDream.Core.Items; + +/// +/// Outcome of — retail +/// VendorSellUI::DragItemAcceptable's (pc:201195-201307, +/// 0x004c20c0) full gate chain: ownership, the non-empty-container +/// bypass, then VendorProfile::InqAcceptability +/// (pc:484768-484797, 0x005d1a90). +/// +public enum VendorSellRejection +{ + /// Acceptable — stage the drop. + None = 0, + + /// ACCWeenieObject::IsOwnedByPlayer(esi) == 0 (pc:201206). + NotOwnedByPlayer, + + /// + /// InqAcceptability returned the raw item_types bitmask + /// (the type-mismatch/non-sellable-bit branch, pc:005d1af8) — + /// the generic case in practice, since a genuine bitmask is almost never + /// literally 1-4 (Slice 6b/6c research doc's open question #3). + /// + WrongType, + + /// + /// InqAcceptability literally returned 1 — retail's + /// DragItemAcceptable switch has a case for it + /// (pc:201259-201267), but nothing in InqAcceptability's + /// own body ever produces a literal 1 (only 0, 2, 3, the "too valuable" + /// value, or the raw type bitmask) — ported for exact control-flow + /// fidelity per CLAUDE.md's "do not simplify the switch" rule, not + /// because it is known to be reachable. + /// + CannotBeSoldHere, + + /// InqAcceptability == 2: per-unit value is exactly zero (pc:005d1ac3). + NoValue, + + /// + /// InqAcceptability's "too valuable" branch (pc:005d1add): + /// max_value != -1 && value > max_value. + /// + TooValuable, + + /// + /// InqAcceptability == 3: min_value != -1 && value < min_value + /// (pc:005d1af2). + /// + TooCheap, +} + +/// +/// Pure port of VendorSellUI::DragItemAcceptable + +/// VendorProfile::InqAcceptability — gates a Selling-tab drop AND its +/// hover-preview cursor (the silent distinction is the caller's job: +/// always computes the same outcome, callers choose +/// whether to surface ). +/// +public static class VendorSellAcceptability +{ + /// Retail's -1/0xffffffff "no limit" sentinel for min_value/max_value. + public const uint NoLimit = uint.MaxValue; + + /// ACCWeenieObject::IsOwnedByPlayer(esi). + /// + /// ACCWeenieObject::GetNumContainedItems(esi) — a non-empty + /// container (a bag with stuff in it) always passes, bypassing the + /// type/value filter entirely (pc:201229-201233). + /// + /// The dragged item's own PublicWeenieDesc::_type. + /// + /// The dragged item's per-unit value — + /// applied to its own Value/StackSize, matching + /// InqAcceptability's own _stackSize > 0 ? _value/_stackSize : _value + /// division (pc:005d1ab2-005d1ab6). + /// + /// The vendor's VendorShopProfile.MerchandiseItemTypes. + /// + /// The vendor's VendorShopProfile.MerchandiseMinValue — + /// means retail's unset -1. + /// + /// + /// The vendor's VendorShopProfile.MerchandiseMaxValue — + /// means retail's unset -1. + /// + public static VendorSellRejection Evaluate( + bool ownedByPlayer, + int containedItemCount, + uint itemTypeMask, + int perUnitValue, + uint merchandiseItemTypes, + uint merchandiseMinValue, + uint merchandiseMaxValue) + { + if (!ownedByPlayer) + return VendorSellRejection.NotOwnedByPlayer; + if (containedItemCount > 0) + return VendorSellRejection.None; + + if ((itemTypeMask & merchandiseItemTypes) == 0u) + return VendorSellRejection.WrongType; + + if (perUnitValue == 0) + return VendorSellRejection.NoValue; + + if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue) + return VendorSellRejection.TooValuable; + + if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue) + return VendorSellRejection.TooCheap; + + return VendorSellRejection.None; + } + + /// + /// Retail's exact rejection strings, read directly out of the decompiled + /// binary's data segment (docs/research/named-retail/acclient_2013_pseudo_c.txt, + /// addresses 0x007b51a8/0x007b51e0/0x007b5230/ + /// 0x007b5278/0x007b52cc/0x007b5308) resolving the + /// truncated "…" citations the Slice 6b/6c research doc quoted. Delivered + /// via ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...) — the SAME + /// system-message notice channel (0x1a) every other retail-ported + /// transient string in this codebase already uses. + /// + public static string? MessageFor(VendorSellRejection rejection) => rejection switch + { + VendorSellRejection.None => null, + VendorSellRejection.NotOwnedByPlayer => "You can only sell items you are carrying", + VendorSellRejection.CannotBeSoldHere => "That item cannot be sold here", + VendorSellRejection.NoValue => "That item has no value and cannot be sold", + VendorSellRejection.TooCheap => "That item is too cheap to sell here", + VendorSellRejection.TooValuable => "That item is too valuable to sell here", + VendorSellRejection.WrongType => "You cannot sell that here", + _ => "You cannot sell that here", + }; +} diff --git a/src/AcDream.Core/Items/VendorStagingList.cs b/src/AcDream.Core/Items/VendorStagingList.cs new file mode 100644 index 00000000..e327c5fd --- /dev/null +++ b/src/AcDream.Core/Items/VendorStagingList.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// One staged Buying/Selling-tab row — a shop/pack item guid plus a staged quantity. +public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity); + +/// +/// Client-local staging list backing retail's gmVendorUI::m_buyList/ +/// m_sellList (both a PackableList<ItemProfile>) — nothing here +/// ever reaches the wire; only a batched Buy All/Sell All send reads +/// to build one wire payload. Shared by the Buying tab +/// (VendorBuyUI) and the Selling tab (VendorSellUI) — retail keeps +/// two nearly-parallel classes over the SAME PackableList shape (Slice +/// 6b/6c research doc's open question #2); this is the one generic list both +/// tab controllers own an instance of, rather than two near-duplicate types. +/// +public sealed class VendorStagingList +{ + private readonly List _entries = new(); + + public IReadOnlyList Entries => _entries; + public bool IsEmpty => _entries.Count == 0; + + public event Action? Changed; + + /// + /// Port of the Buying tab's VendorItemsUI::AddToBuyList insertion + /// (Slice 6b/6c research doc §Q3): stages + /// units of . Re-adding an already-staged guid + /// (e.g. pressing "Add to List" again after moving the slider) UPSERTS + /// the entry to the new quantity rather than appending a duplicate row — + /// retail's own RemoveProfileFromList looks up an entry BY GUID + /// (a single match), which only holds if AddToBuyList never + /// produces two rows for the same guid; the decomp excerpt available to + /// this port does not show the insert side of that invariant directly, + /// so this is a deliberate, documented inference from the removal side's + /// single-match contract, not a byte-verified citation. + /// + public void Add(uint itemGuid, int quantity) + { + if (itemGuid == 0u || quantity <= 0) + return; + + int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid); + if (index >= 0) + _entries[index] = new VendorStagingEntry(itemGuid, quantity); + else + _entries.Add(new VendorStagingEntry(itemGuid, quantity)); + Changed?.Invoke(); + } + + /// + /// Port of gmVendorUI::RemoveProfileFromList (pc:200497-200537, + /// 0x004c1260): == -1 (retail's + /// 0xffffffff) or >= the staged quantity removes the WHOLE + /// entry; otherwise decrements it in place. Returns + /// with no effect for an unstaged guid. + /// + public bool Remove(uint itemGuid, int amount) + { + int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid); + if (index < 0) + return false; + + VendorStagingEntry entry = _entries[index]; + if (amount == -1 || amount >= entry.Quantity) + _entries.RemoveAt(index); + else + _entries[index] = entry with { Quantity = entry.Quantity - amount }; + Changed?.Invoke(); + return true; + } + + public bool TryGet(uint itemGuid, out VendorStagingEntry entry) + { + int index = _entries.FindIndex(e => e.ItemGuid == itemGuid); + if (index < 0) + { + entry = default; + return false; + } + entry = _entries[index]; + return true; + } + + /// Port of the unconditional PackableList<ItemProfile>::Flush calls + /// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All + /// perform immediately after their wire send — see the batched-send call sites). + public void Clear() + { + if (_entries.Count == 0) + return; + _entries.Clear(); + Changed?.Invoke(); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index cd7a2849..fb84420b 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -430,19 +430,95 @@ public sealed class SelectionInteractionControllerTests Assert.Equal(0, h.Items.BusyCount); } + /// + /// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor- + /// completion-research.md Q2): an out-of-range Use kicks off the SAME + /// local client-predicted MoveToObject approach Pickup's far-range + /// branch already installs, giving the walk immediate visual feel. The + /// wire send is never gated on arrival — retail's + /// ItemHolder::UseObject @ 0x00588A80 has no range check and + /// sends unconditionally, so the dispatch and the approach both happen + /// at click time, in that order. A later natural MoveTo completion must + /// not re-dispatch (Use has no post-arrival token the way Pickup does). + /// [Fact] - public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry() + public void FarUseApproachesThenDispatchesImmediatelyAndDoesNotRetryOnArrival() { var h = new Harness(); h.SetApproach(closeRange: false); h.Controller.SendUse(Target); + + PlayerInteractionMovementSinkAssertSingleApproach(h, Target); + Assert.Equal(new[] { Target }, h.Transport.Uses); + h.Controller.OnNaturalMoveToComplete(); - Assert.Empty(h.Movement.Approaches); Assert.Equal(new[] { Target }, h.Transport.Uses); } + /// + /// C1 cancellation coverage: a second far Use command (the player picked + /// a new target, i.e. "moved on") supersedes the first local approach + /// cleanly — no exception, no missing/duplicated dispatch, no leaked + /// pending-pickup state (Use never arms one). + /// + [Fact] + public void NewFarUseCommandSupersedesThePreviousApproachCleanly() + { + const uint otherTarget = 0x7000_0099u; + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = otherTarget, + Name = "Other", + Type = ItemType.Creature, + Useability = ItemUseability.Remote, + }); + h.SetApproach(closeRange: false); + + h.Controller.SendUse(Target); + h.SetApproach(closeRange: false, serverGuid: otherTarget); + h.Controller.SendUse(otherTarget); + + Assert.Equal(2, h.Movement.Approaches.Count); + Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid); + Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid); + Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses); + + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses); + } + + /// + /// C1 cancellation coverage: the underlying MoveTo controller cancelling + /// out from under a far Use's local approach (player moved away with + /// WASD, or any other source of ) must not + /// retract or duplicate the Use, which already went out unconditionally + /// at click time — Use holds no pending-pickup state for + /// OnMoveToCancelled to touch. + /// + [Fact] + public void MovingAwayDuringAFarUseApproachDoesNotAffectTheAlreadyDispatchedUse() + { + var h = new Harness(); + h.SetApproach(closeRange: false); + + h.Controller.SendUse(Target); + h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + private static void PlayerInteractionMovementSinkAssertSingleApproach( + Harness h, uint expectedTarget) + { + InteractionApproach approach = Assert.Single(h.Movement.Approaches); + Assert.Equal(expectedTarget, approach.Target.ServerGuid); + } + [Fact] public void CarriedDirectUseBypassesWorldApproachAndWaitsForUseDone() { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 9ed61758..30ba6acb 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -33,6 +33,12 @@ public sealed class ItemInteractionControllerTests // TryBuy must see this as false and release the reservation // rather than mark it dispatched for a request nothing sent. public bool SendBuySucceeds = true; + // Slice 6b/6c: the batched Buy All / Sell send delegates — same + // "no live session" no-op simulation shape as SendBuySucceeds. + public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); + public bool SendBuyAllSucceeds = true; + public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public bool SendSellSucceeds = true; public readonly List Toasts = new(); public readonly List SystemMessages = new(); public readonly List CombatModeRequests = new(); @@ -107,6 +113,20 @@ public sealed class ItemInteractionControllerTests return false; Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)); return true; + }, + sendBuyAll: (vendorGuid, items, alternateCurrencyId) => + { + if (!SendBuyAllSucceeds) + return false; + BuyAlls.Add((vendorGuid, items, alternateCurrencyId)); + return true; + }, + sendSell: (vendorGuid, items) => + { + if (!SendSellSucceeds) + return false; + Sells.Add((vendorGuid, items)); + return true; }); } @@ -2308,4 +2328,182 @@ public sealed class ItemInteractionControllerTests Assert.Single(h.Buys); Assert.Equal(1, h.Controller.BusyCount); } + + // ── Slice 6b: TryBuyAll ────────────────────────────────────────────── + + [Fact] + public void TryBuyAll_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation() + { + var h = new Harness(); + var items = new (int Amount, uint ItemGuid)[] + { + (1, 0x50002000u), + (25, 0x50002001u), + }; + + bool result = h.Controller.TryBuyAll(0x40001000u, items, alternateCurrencyId: 0u); + + Assert.True(result); + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent, uint currency) = Assert.Single(h.BuyAlls); + Assert.Equal(0x40001000u, vendorGuid); + Assert.Equal(items, sent); + Assert.Equal(0u, currency); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void TryBuyAll_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse() + { + var h = new Harness(); + h.Controller.IncrementBusyCount(); + + bool result = h.Controller.TryBuyAll( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u); + + Assert.False(result); + Assert.Empty(h.BuyAlls); + } + + [Theory] + [InlineData(0u)] + [InlineData(0x40001000u)] + public void TryBuyAll_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid) + { + var h = new Harness(); + var items = vendorGuid == 0u + ? new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) } + : Array.Empty<(int Amount, uint ItemGuid)>(); + + bool result = h.Controller.TryBuyAll(vendorGuid, items, 0u); + + Assert.False(result); + Assert.Empty(h.BuyAlls); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void TryBuyAll_CompleteUse_ReleasesTheReservation() + { + var h = new Harness(); + Assert.True(h.Controller.TryBuyAll( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u)); + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.CompleteUse(0); + + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void TryBuyAll_NoSessionToSendOn_ReleasesTheReservation() + { + var h = new Harness(); + h.SendBuyAllSucceeds = false; + + bool result = h.Controller.TryBuyAll( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u); + + Assert.False(result); + Assert.Empty(h.BuyAlls); + Assert.Equal(0, h.Controller.BusyCount); + } + + // ── Slice 6c: TrySell ──────────────────────────────────────────────── + + [Fact] + public void TrySell_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation() + { + var h = new Harness(); + var items = new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }; + + bool result = h.Controller.TrySell(0x40001000u, items); + + Assert.True(result); + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent) = Assert.Single(h.Sells); + Assert.Equal(0x40001000u, vendorGuid); + Assert.Equal(items, sent); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void TrySell_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse() + { + var h = new Harness(); + h.Controller.IncrementBusyCount(); + + bool result = h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }); + + Assert.False(result); + Assert.Empty(h.Sells); + } + + [Fact] + public void TrySell_ASecondSellWhileTheFirstIsInFlight_IsRejected() + { + var h = new Harness(); + Assert.True(h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) })); + + bool second = h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) }); + + Assert.False(second); + Assert.Single(h.Sells); + } + + [Theory] + [InlineData(0u)] + [InlineData(0x40001000u)] + public void TrySell_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid) + { + var h = new Harness(); + var items = vendorGuid == 0u + ? new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) } + : Array.Empty<(int Amount, uint ItemGuid)>(); + + bool result = h.Controller.TrySell(vendorGuid, items); + + Assert.False(result); + Assert.Empty(h.Sells); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void TrySell_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests() + { + var h = new Harness(); + Assert.True(h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) })); + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.CompleteUse(0); + + Assert.Equal(0, h.Controller.BusyCount); + Assert.True(h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) })); + Assert.Equal(2, h.Sells.Count); + } + + [Fact] + public void TrySell_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentSellWorks() + { + var h = new Harness(); + h.SendSellSucceeds = false; + + bool result = h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }); + + Assert.False(result); + Assert.Empty(h.Sells); + Assert.Equal(0, h.Controller.BusyCount); + + h.SendSellSucceeds = true; + bool second = h.Controller.TrySell( + 0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) }); + + Assert.True(second); + Assert.Single(h.Sells); + Assert.Equal(1, h.Controller.BusyCount); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index 2bc49ce5..b9b40859 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -645,4 +645,117 @@ public class SelectedObjectControllerTests Assert.False(healthMeterEl.Visible); Assert.Empty(h.QueryHealthCalls); } + + // ══════════════════════════════════════════════════════════════════════ + // C4 (Slice 6b/6c contract, Q5 — user evidence is the axiom): reproduce + // the user's live-session report of a missing "(250,000)" figure near a + // selected vendor stack, against the REAL production wiring — a real + // ClientObjectTable + VendorState + VendorSplitPolicy (mirroring + // InteractionRetainedUiComposition's exact IsVendorSplitExempt/ + // StackSize/name lambdas verbatim, not the fake dictionary-backed + // Harness the rest of this file uses), mounted onto the real dat- + // fixture toolbar layout (FixtureLoader.LoadToolbar(), LayoutDesc + // 0x21000016) through the production SelectedObjectController.Bind + // entry point. + // + // docs/research/2026-08-08-slice6b-vendor-completion-research.md Q5 + // concluded from static reading that retail's toolbar strip never + // shows a price/value suffix in the object name (only "{count} + // {name}") and that a "(250,000)" figure belongs to the VENDOR ROW's + // own price text (VendorUiController._itemCostText), a separate + // widget — with every acdream link in the chain (name formatting, + // slider seed, the shared VendorSplitPolicy mask, the materializer's + // field mapping) already matching that shape on paper. This test + // settles whether that is genuinely what the user saw (expected: this + // test passes, and the caller stops here per the contract, reporting + // this as the live-probe evidence) or whether the real wiring chain + // has a defect static reading could not see (expected: this test + // fails, and the failure is what gets fixed). + // ══════════════════════════════════════════════════════════════════════ + + [Fact] + public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation() + { + const uint vendorGuid = 0x70000010u; + const uint tradeNotesGuid = 0x60009001u; + + ImportedLayout layout = FixtureLoader.LoadToolbar(); + var objects = new ClientObjectTable(); + var vendor = new VendorState(); + var selection = new SelectionState(); + var splitQuantity = new StackSplitQuantityState(); + + // A materialized vendor shop item — VendorShopItemMaterializer. + // ToWeenieData's exact shape: StackSize is the item's own + // authored per-unit stack depth (its DescStackSize wire field), + // ContainerId is the vendor's own guid. + objects.AddOrUpdate(new ClientObject + { + ObjectId = tradeNotesGuid, + Name = "Trade Note", + PluralName = "Trade Notes", + Type = ItemType.PromissoryNote, + StackSize = 250, + ContainerId = vendorGuid, + }); + vendor.Apply( + vendorGuid, + new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""), + Array.Empty()); + + SelectedObjectController controller = SelectedObjectController.Bind( + layout, + selection, + subscribeHealthChanged: _ => { }, + unsubscribeHealthChanged: _ => { }, + subscribeItemManaChanged: _ => { }, + unsubscribeItemManaChanged: _ => { }, + isHealthTarget: _ => false, + isOwnedByPlayer: _ => false, + // Production's EXACT name resolver (InteractionRetainedUiComposition.cs:676). + name: guid => objects.Get(guid)?.GetAppropriateName(), + healthPercent: _ => 0f, + hasHealth: _ => false, + // Production's EXACT stackSize resolver (InteractionRetainedUiComposition.cs:679-680). + stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0), + sendQueryHealth: _ => { }, + manaPercent: _ => 0f, + sendQueryItemMana: _ => { }, + datFont: null, + splitQuantity: splitQuantity, + subscribeObjectUpdated: _ => { }, + unsubscribeObjectUpdated: _ => { }, + // Production's EXACT isVendorSplitExempt predicate, verbatim + // from InteractionRetainedUiComposition.cs:698-702. + isVendorSplitExempt: guid => + vendor.VendorId != 0u + && objects.Get(guid) is { } vendorCandidate + && vendorCandidate.ContainerId == vendor.VendorId + && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)); + + selection.Select(tradeNotesGuid, SelectionChangeSource.Vendor); + + var nameElement = layout.FindElement(SelectedObjectController.NameId); + Assert.NotNull(nameElement); + UiText nameLabel = Assert.Single(nameElement!.Children.OfType()); + string renderedName = string.Concat( + nameLabel.LinesProvider().Select(static line => line.Text)); + + var slider = Assert.IsType( + layout.FindElement(SelectedObjectController.StackSizeSliderId)); + + // Retail's toolbar name text: "{stackSize} {name}" — count is the + // raw authored stack (250), independent of the vendor-exempt SEED. + // No parenthetical value anywhere in this string. + Assert.Equal("250 Trade Notes", renderedName); + Assert.DoesNotContain("250,000", renderedName); + Assert.DoesNotContain("(", renderedName); + // The slider is visible (a real multi-unit stack) and seeds to 1 — + // PromissoryNote intersects VendorSplitPolicy.SplitExemptMask. + Assert.True(slider.Visible); + Assert.Equal(1u, splitQuantity.Value); + Assert.Equal(250u, splitQuantity.Maximum); + + controller.Dispose(); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index e2ea658d..de8fe171 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -24,6 +24,16 @@ public sealed class VendorUiControllerTests private const uint ArmorItemGuid = 0x60000101u; private const uint FoodItemGuid = 0x60000102u; private const uint StackedItemGuid = 0x60000103u; + // Slice 6b: a second same-category shop item, so two DIFFERENT items + // both appear in the SAME category-filtered Items list simultaneously + // (Armor and Food are different table entries and can't both show at + // once without touching the category dropdown). + private const uint AnotherArmorItemGuid = 0x60000104u; + // Slice 6c: player-OWNED pack items (never vendor stock) dragged onto + // the Selling tab. + private const uint PlayerOwnedArmorGuid = 0x60000201u; + private const uint PlayerOwnedWeaponGuid = 0x60000202u; + private const uint PlayerOwnedArmorGuid2 = 0x60000205u; private sealed class TestElement : UiElement { } @@ -172,11 +182,28 @@ public sealed class VendorUiControllerTests public readonly UiButton CloseButton; public readonly UiButton BuyButton; public readonly UiButton AddButton; + // Slice 6b: "Buying" tab staging widgets. + public readonly UiItemList BuyingList = new(); + public readonly UiButton BuyItemButton; + public readonly UiButton BuyAllButton; + public readonly UiButton BuyClearItemButton; + public readonly UiButton BuyClearListButton; + // Slice 6c: "Selling" tab staging widgets. + public readonly UiItemList SellingList = new(); + public readonly UiButton SellItemButton; + public readonly UiButton SellAllButton; + public readonly UiButton SellClearItemButton; + public readonly UiButton SellClearListButton; 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 List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); + public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public readonly List SystemMessages = new(); public readonly ItemInteractionController ItemInteraction; + public readonly RetailDialogFactory Dialogs; + public ImportedLayout? ShownDialog; public Harness() { @@ -194,6 +221,30 @@ public sealed class VendorUiControllerTests AddButton = new UiButton( new ElementInfo { Id = VendorUiController.AddButtonId, Type = 1 }, static _ => (0u, 0, 0)); + BuyItemButton = new UiButton( + new ElementInfo { Id = VendorUiController.BuyItemButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + BuyAllButton = new UiButton( + new ElementInfo { Id = VendorUiController.BuyAllButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + BuyClearItemButton = new UiButton( + new ElementInfo { Id = VendorUiController.BuyClearItemButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + BuyClearListButton = new UiButton( + new ElementInfo { Id = VendorUiController.BuyClearListButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + SellItemButton = new UiButton( + new ElementInfo { Id = VendorUiController.SellItemButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + SellAllButton = new UiButton( + new ElementInfo { Id = VendorUiController.SellAllButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + SellClearItemButton = new UiButton( + new ElementInfo { Id = VendorUiController.SellClearItemButtonId, Type = 1 }, + static _ => (0u, 0, 0)); + SellClearListButton = new UiButton( + new ElementInfo { Id = VendorUiController.SellClearListButtonId, Type = 1 }, + static _ => (0u, 0, 0)); root.AddChild(CloseButton); root.AddChild(ItemsTab); @@ -209,6 +260,16 @@ public sealed class VendorUiControllerTests ItemsPage.AddChild(ItemCostText); ItemsPage.AddChild(BuyButton); ItemsPage.AddChild(AddButton); + BuyingPage.AddChild(BuyingList); + BuyingPage.AddChild(BuyItemButton); + BuyingPage.AddChild(BuyAllButton); + BuyingPage.AddChild(BuyClearItemButton); + BuyingPage.AddChild(BuyClearListButton); + SellingPage.AddChild(SellingList); + SellingPage.AddChild(SellItemButton); + SellingPage.AddChild(SellAllButton); + SellingPage.AddChild(SellClearItemButton); + SellingPage.AddChild(SellClearListButton); var layout = new ImportedLayout(root, new Dictionary { @@ -226,6 +287,16 @@ public sealed class VendorUiControllerTests [VendorUiController.ItemCostTextId] = ItemCostText, [VendorUiController.BuyButtonId] = BuyButton, [VendorUiController.AddButtonId] = AddButton, + [VendorUiController.BuyingListId] = BuyingList, + [VendorUiController.BuyItemButtonId] = BuyItemButton, + [VendorUiController.BuyAllButtonId] = BuyAllButton, + [VendorUiController.BuyClearItemButtonId] = BuyClearItemButton, + [VendorUiController.BuyClearListButtonId] = BuyClearListButton, + [VendorUiController.SellingListId] = SellingList, + [VendorUiController.SellItemButtonId] = SellItemButton, + [VendorUiController.SellAllButtonId] = SellAllButton, + [VendorUiController.SellClearItemButtonId] = SellClearItemButton, + [VendorUiController.SellClearListButtonId] = SellClearListButton, }); Window = RetailWindowFrame.Mount( @@ -255,8 +326,21 @@ public sealed class VendorUiControllerTests { Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)); return true; + }, + sendBuyAll: (vendorGuid, items, alternateCurrencyId) => + { + BuyAlls.Add((vendorGuid, items, alternateCurrencyId)); + return true; + }, + sendSell: (vendorGuid, items) => + { + Sells.Add((vendorGuid, items)); + return true; }); + Dialogs = new RetailDialogFactory(Screen, _ => + ShownDialog = FixtureLoader.LoadConfirmationDialog()); + Controller = VendorUiController.Bind( layout, State, @@ -272,7 +356,9 @@ public sealed class VendorUiControllerTests SplitQuantity, datFont: null, debugFont: null, - static _ => (0u, 0, 0))!; + static _ => (0u, 0, 0), + dialogs: Dialogs, + systemMessage: SystemMessages.Add)!; Screen.WindowManager.AttachController(WindowNames.Vendor, Controller); } } @@ -281,6 +367,17 @@ public sealed class VendorUiControllerTests float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) => new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName); + /// + /// Slice 6c: a profile shaped for + /// coverage — 's all-zero merchandise fields would + /// reject every real item (MaxValue=0 rejects anything with value > 0). + /// + private static VendorShopProfile SellProfile( + uint merchandiseItemTypes, + uint minValue = 0u, + uint maxValue = VendorSellAcceptability.NoLimit) => + new(merchandiseItemTypes, minValue, maxValue, false, 1.0f, 1.5f, 0u, 0u, ""); + private static string GetText(UiText text) => string.Concat(text.LinesProvider().Select(line => line.Text)); @@ -528,13 +625,11 @@ public sealed class VendorUiControllerTests } [Fact] - public void AddButton_IsPermanentlyDisabled_RegardlessOfSelection() + public void AddButton_EnablesWithSelection_NowThatStagingIsWired() { - // F8 (Slice 6 review): "Add to List" has no wired OnClick at all - // (staging into the "Buying" tab is deferred, contract decision 6) - // — an enabled button that silently does nothing on click is a - // dead-affordance bug, worse than a disabled one. It must never - // enable, with or without a selection. + // Slice 6b (AP-161 F8 residual closes): "Add to List" now stages + // into the "Buying" tab and enables with selection exactly like Buy + // — an enabled Add is no longer a dead affordance. var h = new Harness(); Assert.False(h.AddButton.Enabled); @@ -543,8 +638,8 @@ public sealed class VendorUiControllerTests new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), }); - // F4/F6 auto-selects the sole item on open -- Add stays disabled. - Assert.False(h.AddButton.Enabled); + // F4/F6 auto-selects the sole item on open -- Add now enables too. + Assert.True(h.AddButton.Enabled); h.State.Close(); Assert.False(h.AddButton.Enabled); @@ -957,7 +1052,7 @@ public sealed class VendorUiControllerTests } [Fact] - public void SellingTab_SwitchesPageButPopulatesNoSellContent() + public void SellingTab_SwitchesPageAndStartsWithAnEmptyStagedSellList() { var h = new Harness(); h.State.Apply(VendorGuid, Profile(), new[] @@ -970,13 +1065,15 @@ public sealed class VendorUiControllerTests Assert.True(h.SellingPage.Visible); Assert.False(h.ItemsPage.Visible); Assert.False(h.BuyingPage.Visible); - // Slice 6 fence: no sell-list/price/button wiring exists at all — - // the page is exactly the authored-empty container it started as. - Assert.Empty(h.SellingPage.Children); + // Slice 6c: the Selling tab's own widgets are wired now (staging + // list + four buttons), but nothing is STAGED without a drag/drop — + // the list itself stays empty. + Assert.NotEmpty(h.SellingPage.Children); + Assert.Equal(0, h.SellingList.GetNumUIItems()); } [Fact] - public void BuyingTab_SwitchesPageButPopulatesNoBuyContent() + public void BuyingTab_SwitchesPageAndStartsWithAnEmptyStagedBuyList() { var h = new Harness(); h.State.Apply(VendorGuid, Profile(), new[] @@ -988,7 +1085,11 @@ public sealed class VendorUiControllerTests Assert.True(h.BuyingPage.Visible); Assert.False(h.ItemsPage.Visible); - Assert.Empty(h.BuyingPage.Children); + // Slice 6b: the Buying tab's own widgets are wired now (staging list + // + four buttons), but nothing is STAGED until "Add to List" is + // pressed — the list itself stays empty. + Assert.NotEmpty(h.BuyingPage.Children); + Assert.Equal(0, h.BuyingList.GetNumUIItems()); } [Fact] @@ -1237,4 +1338,552 @@ public sealed class VendorUiControllerTests Assert.Equal(string.Empty, GetText(h.ItemNameText)); Assert.False(h.BuyButton.Enabled); } + + // ══════════════════════════════════════════════════════════════════════ + // Slice 6b — Buying tab staging (Add to List, Buy Item, Buy All, Clear) + // ══════════════════════════════════════════════════════════════════════ + + [Fact] + public void AddToBuyList_StagesTheSelectedItem() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + // F4/F6 auto-selects the sole item on open. + + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId); + } + + [Fact] + public void AddToBuyList_ReAddingTheSameItemUpsertsRatherThanDuplicatingTheRow() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + + h.AddButton.OnClick!.Invoke(); + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void AddToBuyList_NothingSelected_IsANoOp() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), Array.Empty()); + + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150), + }); + + h.ItemList.GetItem(0)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + h.ItemList.GetItem(1)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(2, h.BuyingList.GetNumUIItems()); + + h.BuyAllButton.OnClick!.Invoke(); + + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint currency) = + Assert.Single(h.BuyAlls); + Assert.Equal(VendorGuid, vendorGuid); + Assert.Equal( + new (int Amount, uint ItemGuid)[] { (1, ArmorItemGuid), (1, AnotherArmorItemGuid) }, + items); + Assert.Equal(0u, currency); + // Retail flushes m_buyList immediately after the send, not gated on + // a server response (pc:204075-204076) — see BuyAllButtonPressed's + // own doc comment. + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void BuyAllButton_WithNothingStaged_IsANoOp() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), Array.Empty()); + + h.BuyAllButton.OnClick!.Invoke(); + + Assert.Empty(h.BuyAlls); + } + + [Fact] + public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + + h.BuyItemButton.OnClick!.Invoke(); + + Assert.Equal(new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, h.Buys); + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void BuyClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutBuying() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150), + }); + h.ItemList.GetItem(0)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + h.ItemList.GetItem(1)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(2, h.BuyingList.GetNumUIItems()); + // AnotherArmorItemGuid is currently selected (last clicked). + + h.BuyClearItemButton.OnClick!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId); + Assert.Empty(h.Buys); + } + + [Fact] + public void BuyClearListButton_ClearsEveryStagedEntryWithoutBuying() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + + h.BuyClearListButton.OnClick!.Invoke(); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + Assert.Empty(h.Buys); + } + + // ══════════════════════════════════════════════════════════════════════ + // Slice 6c — Selling tab drag-to-sell staging + // ══════════════════════════════════════════════════════════════════════ + + private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1) + { + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Item {guid:X8}", + Type = type, + Value = value, + StackSize = stackSize, + }); + h.Objects.MoveItem(guid, Harness.PlayerGuid, h.Objects.GetContents(Harness.PlayerGuid).Count); + } + + private static ItemDragPayload DragFromInventory(uint guid) => + new(guid, ItemDragSource.Inventory, 0, new UiItemSlot()); + + [Fact] + public void OnDragOver_TargetIsNotTheSellingList_RejectsRegardlessOfAcceptability() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + + ItemDragAcceptance result = h.Controller.OnDragOver( + h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(ItemDragAcceptance.Reject, result); + } + + [Fact] + public void OnDragOver_AcceptableItemOverSellingList_Accepts() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + + ItemDragAcceptance result = h.Controller.OnDragOver( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(ItemDragAcceptance.Accept, result); + } + + [Fact] + public void OnDragOver_UnacceptableItemOverSellingList_RejectsSilently() + { + var h = new Harness(); + // Vendor only deals in Armor -- a Weapon is a type mismatch. + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100); + + ItemDragAcceptance result = h.Controller.OnDragOver( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + + Assert.Equal(ItemDragAcceptance.Reject, result); + // silent=1 on hover -- no rejection string yet (VendorSellUI:: + // OnItemListDragOver, pc:201320-201339). + Assert.Empty(h.SystemMessages); + } + + [Fact] + public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(1, h.SellingList.GetNumUIItems()); + Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId); + Assert.True(h.SellingPage.Visible); + Assert.False(h.ItemsPage.Visible); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + Assert.Empty(h.SystemMessages); + } + + [Fact] + public void HandleDropRelease_WrongTargetList_IsIgnored() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + + // gmVendorUI::HandleDropRelease's IsAncestorOfMe gate — a drop on + // ANY other list in the panel (here, the Items list) is a structural + // no-op, never reaching AcceptDragObject. + h.Controller.HandleDropRelease( + h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + } + + [Fact] + public void HandleDropRelease_UnacceptableType_ShowsTheGenericRejectionMessageAndDoesNotStage() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages); + } + + [Fact] + public void HandleDropRelease_NoValueItem_ShowsTheNoValueRejectionMessage() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, value: 0); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(new[] { "That item has no value and cannot be sold" }, h.SystemMessages); + } + + [Fact] + public void HandleDropRelease_NotOwnedByPlayer_ShowsTheOwnershipRejectionMessage() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + // Registered but never moved into the player's own container -- + // ContainerId/WielderId both stay 0, so IsOwnedByPlayer is false. + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = PlayerOwnedArmorGuid, + Name = "Someone else's chainmail", + Type = ItemType.Armor, + Value = 100, + StackSize = 1, + }); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages); + } + + [Fact] + public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2)); + Assert.Equal(2, h.SellingList.GetNumUIItems()); + + h.SellAllButton.OnClick!.Invoke(); + + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); + Assert.Equal(VendorGuid, vendorGuid); + Assert.Equal( + new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid), (1, PlayerOwnedArmorGuid2) }, + items); + Assert.Equal(0, h.SellingList.GetNumUIItems()); + } + + [Fact] + public void SellAllButton_WithNothingStaged_IsANoOp() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + + h.SellAllButton.OnClick!.Invoke(); + + Assert.Empty(h.Sells); + } + + [Fact] + public void SellItemButton_SellsTheSelectedStagedItemAndRemovesItUnconditionallyOnSuccess() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + + h.SellItemButton.OnClick!.Invoke(); + + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); + Assert.Equal(VendorGuid, vendorGuid); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items); + Assert.Equal(0, h.SellingList.GetNumUIItems()); + } + + [Fact] + public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2)); + Assert.Equal(2, h.SellingList.GetNumUIItems()); + // PlayerOwnedArmorGuid2 is currently selected (last dropped). + + h.SellClearItemButton.OnClick!.Invoke(); + + Assert.Equal(1, h.SellingList.GetNumUIItems()); + Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId); + Assert.Empty(h.Sells); + } + + [Fact] + public void SellClearListButton_ClearsEveryStagedEntryWithoutSelling() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + h.SellClearListButton.OnClick!.Invoke(); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Empty(h.Sells); + } + + // ══════════════════════════════════════════════════════════════════════ + // Slice 6b/6c — X-close staging confirmation + session-boundary clears + // ══════════════════════════════════════════════════════════════════════ + + [Fact] + public void CloseButtonPressed_WithNoStaging_HidesImmediatelyWithoutADialog() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + Assert.True(h.Window.IsVisible); + + h.CloseButton.OnClick!.Invoke(); + + Assert.False(h.Window.IsVisible); + Assert.False(h.Dialogs.IsOpen); + } + + [Fact] + public void CloseButtonPressed_WithStagedBuyItems_ShowsConfirmDialogInsteadOfHidingImmediately() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + + h.CloseButton.OnClick!.Invoke(); + + Assert.True(h.Window.IsVisible); + Assert.True(h.Dialogs.IsOpen); + Assert.NotNull(h.ShownDialog); + // Exact retail string, read from the decompiled binary's data + // segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. + Assert.Equal( + "You have not completed all transactions. Are you sure you want to leave this vendor", + string.Join(" ", Assert.IsType(h.ShownDialog!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text))); + } + + [Fact] + public void CloseConfirmDialog_Accepted_HidesTheWindowAndLeavesStagingIntact() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + h.CloseButton.OnClick!.Invoke(); + + Assert.IsType(h.ShownDialog!.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.False(h.Window.IsVisible); + Assert.False(h.Dialogs.IsOpen); + // Retail's CloseVendorDialogCallback never touches m_buyList/ + // m_sellList -- staging survives so the next open shows it again. + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void CloseConfirmDialog_Rejected_KeepsTheWindowOpenAndStagingIntact() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + h.CloseButton.OnClick!.Invoke(); + + Assert.IsType(h.ShownDialog!.FindElement( + RetailConfirmationDialogView.RejectButtonId)).OnClick!(); + + Assert.True(h.Window.IsVisible); + Assert.False(h.Dialogs.IsOpen); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void CloseButtonPressed_WhileAConfirmationIsAlreadyUp_DoesNotOpenASecondOne() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + h.CloseButton.OnClick!.Invoke(); + Assert.Equal(1, h.Dialogs.ActiveCount); + + h.CloseButton.OnClick!.Invoke(); + + Assert.Equal(1, h.Dialogs.ActiveCount); + } + + [Fact] + public void SessionClose_ClearsBothStagingLists() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + + h.State.Close(); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + Assert.Equal(0, h.SellingList.GetNumUIItems()); + } + + [Fact] + public void SessionReset_ClearsBothStagingLists() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + + h.State.Reset(); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void OpeningADifferentVendor_ClearsStaleStagingFromThePreviousVendor() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + + const uint otherVendor = 0x70000099u; + h.State.Apply(otherVendor, Profile(), new[] + { + new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5), + }); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + } + + [Fact] + public void RefreshedTransition_SameVendor_DoesNotClearAnUntouchedStagingList() + { + var h = new Harness(); + var items = new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }; + h.State.Apply(VendorGuid, Profile(), items); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + + // Same vendor id re-approaching -- sameVendor==1, a Refreshed + // transition (e.g. post buy/sell ApproachVendor refresh). + h.State.Apply(VendorGuid, Profile(), items); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs index f23d9178..9f0cb0e2 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs @@ -124,4 +124,75 @@ public sealed class VendorRequestsTests Assert.Equal(viaList, viaSingle); } + + // ---- Slice 6c: BuildSell (0x0060) — no trailing currency field --------- + + [Fact] + public void BuildSell_SingleItem_WritesEnvelopeSequenceOpcodeVendorCountAndItemWithNoTrailer() + { + byte[] body = VendorRequests.BuildSell( + gameActionSequence: 9, + vendorGuid: 0x40001000u, + items: new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }); + + // envelope(4) + seq(4) + opcode(4) + vendorGuid(4) + itemCount(4) + // + 1*(amount(4)+guid(4)) = 28. Note: 4 bytes SHORTER than the + // equivalent single-item Buy payload (32) — Sell has no trailing + // alternateCurrencyId. + Assert.Equal(28, body.Length); + Assert.Equal(VendorRequests.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0))); + Assert.Equal(9u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(VendorRequests.SellOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x0060u, VendorRequests.SellOpcode); + 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))); + } + + [Fact] + public void BuildSell_MultipleItems_WritesEachAmountGuidPairInOrderWithNoTrailer() + { + byte[] body = VendorRequests.BuildSell( + gameActionSequence: 4, + vendorGuid: 0x40001000u, + items: new (int Amount, uint ItemGuid)[] + { + (1, 0x50002000u), + (10, 0x50002001u), + }); + + // 20 + 2*8 = 36. + Assert.Equal(36, 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))); + } + + [Fact] + public void BuildSell_EmptyList_WritesAZeroCountAndNoItemPairs() + { + byte[] body = VendorRequests.BuildSell( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + items: Array.Empty<(int Amount, uint ItemGuid)>()); + + Assert.Equal(20, body.Length); + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + } } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs index 47835008..79bc38c1 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs @@ -61,4 +61,70 @@ public sealed class WorldSessionVendorTests Assert.Equal(expected, captured); } + + // ---- Slice 6b: the batched "Buy All" SendBuy overload ------------------- + + [Fact] + public void SendBuy_BatchedOverload_EmitsBytesIdenticalToVendorRequestsBuildBuyWithAList() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + var items = new (int Amount, uint ItemGuid)[] + { + (1, 0x50002000u), + (10, 0x50002001u), + }; + session.SendBuy(0x40001000u, items, 0u); + + byte[] expected = VendorRequests.BuildBuy( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + items: items, + alternateCurrencyId: 0u); + + Assert.NotNull(captured); + Assert.Equal(expected, captured); + } + + // ---- Slice 6c: SendSell --------------------------------------------- + + [Fact] + public void SendSell_EmitsBytesIdenticalToVendorRequestsBuildSell() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }; + session.SendSell(0x40001000u, items); + + byte[] expected = VendorRequests.BuildSell( + gameActionSequence: 1, + vendorGuid: 0x40001000u, + items: items); + + Assert.NotNull(captured); + Assert.Equal(expected, captured); + } + + [Fact] + public void SendSell_IncrementsTheSharedGameActionSequenceLikeEveryOtherSend() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTalk("first"); // consumes sequence 1 + var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }; + session.SendSell(0x40001000u, items); // should be sequence 2 + + byte[] expected = VendorRequests.BuildSell( + gameActionSequence: 2, + vendorGuid: 0x40001000u, + items: items); + + Assert.Equal(expected, captured); + } } diff --git a/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs b/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs new file mode 100644 index 00000000..35188fd5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs @@ -0,0 +1,174 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +/// +/// Conformance tests for — the port of +/// VendorSellUI::DragItemAcceptable (pc:201195-201307) chained +/// into VendorProfile::InqAcceptability (pc:484768-484797). +/// +public sealed class VendorSellAcceptabilityTests +{ + private const uint Armor = (uint)ItemType.Armor; + private const uint Weapon = (uint)ItemType.Weapon; + private const uint NoLimit = VendorSellAcceptability.NoLimit; + + [Fact] + public void NotOwnedByPlayerIsRejectedBeforeAnyOtherCheck() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: false, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 100, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.NotOwnedByPlayer, rejection); + } + + [Fact] + public void ANonEmptyContainerBypassesTheTypeAndValueFilterEntirely() + { + // pc:201229-201233: GetNumContainedItems > 0 -> accept unconditionally, + // even though the container's OWN type (Weapon) does not intersect + // the vendor's merchandise mask (Armor) and it has zero value. + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 3, + itemTypeMask: Weapon, + perUnitValue: 0, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.None, rejection); + } + + [Fact] + public void AWrongItemTypeIsRejected() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Weapon, + perUnitValue: 100, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.WrongType, rejection); + } + + [Fact] + public void ZeroPerUnitValueIsRejectedAsNoValue() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 0, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.NoValue, rejection); + } + + [Fact] + public void AboveTheAuthoredMaxValueIsRejectedAsTooValuable() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 1001, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: 1000u); + + Assert.Equal(VendorSellRejection.TooValuable, rejection); + } + + [Fact] + public void ExactlyAtTheAuthoredMaxValueIsAcceptable() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 1000, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: 1000u); + + Assert.Equal(VendorSellRejection.None, rejection); + } + + [Fact] + public void BelowTheAuthoredMinValueIsRejectedAsTooCheap() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 4, + merchandiseItemTypes: Armor, + merchandiseMinValue: 5u, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.TooCheap, rejection); + } + + [Fact] + public void NoLimitSentinelDisablesBothMaxAndMinChecks() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: int.MaxValue - 1, + merchandiseItemTypes: Armor, + merchandiseMinValue: NoLimit, + merchandiseMaxValue: NoLimit); + + Assert.Equal(VendorSellRejection.None, rejection); + } + + [Fact] + public void AnOrdinaryAcceptableItemReturnsNone() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 500, + merchandiseItemTypes: Armor, + merchandiseMinValue: 1u, + merchandiseMaxValue: 10_000u); + + Assert.Equal(VendorSellRejection.None, rejection); + } + + // ---- MessageFor: exact retail strings, recovered from the decompiled ---- + // ---- binary's data segment (see VendorSellAcceptability's doc comment). ---- + + [Theory] + [InlineData(VendorSellRejection.NotOwnedByPlayer, "You can only sell items you are carrying")] + [InlineData(VendorSellRejection.CannotBeSoldHere, "That item cannot be sold here")] + [InlineData(VendorSellRejection.NoValue, "That item has no value and cannot be sold")] + [InlineData(VendorSellRejection.TooCheap, "That item is too cheap to sell here")] + [InlineData(VendorSellRejection.TooValuable, "That item is too valuable to sell here")] + [InlineData(VendorSellRejection.WrongType, "You cannot sell that here")] + public void MessageForReturnsRetailsExactString(VendorSellRejection rejection, string expected) + { + Assert.Equal(expected, VendorSellAcceptability.MessageFor(rejection)); + } + + [Fact] + public void MessageForAcceptableReturnsNull() + { + Assert.Null(VendorSellAcceptability.MessageFor(VendorSellRejection.None)); + } +} diff --git a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs new file mode 100644 index 00000000..40048976 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs @@ -0,0 +1,169 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +/// +/// Conformance tests for — retail's +/// gmVendorUI::RemoveProfileFromList (pc:200497-200537) removal +/// semantics, and the Add/Clear staging shape both the Buying and Selling +/// tabs share (Slice 6b/6c). +/// +public sealed class VendorStagingListTests +{ + private const uint ItemA = 0x60000101u; + private const uint ItemB = 0x60000102u; + + [Fact] + public void AddAppendsANewEntry() + { + var list = new VendorStagingList(); + + list.Add(ItemA, 5); + + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(ItemA, entry.ItemGuid); + Assert.Equal(5, entry.Quantity); + Assert.False(list.IsEmpty); + } + + [Fact] + public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating() + { + var list = new VendorStagingList(); + + list.Add(ItemA, 5); + list.Add(ItemA, 20); + + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(20, entry.Quantity); + } + + [Theory] + [InlineData(0u, 5)] + [InlineData(ItemA, 0)] + [InlineData(ItemA, -1)] + public void AddIgnoresAZeroGuidOrNonPositiveQuantity(uint guid, int quantity) + { + var list = new VendorStagingList(); + + list.Add(guid, quantity); + + Assert.True(list.IsEmpty); + } + + [Fact] + public void ChangedFiresOnAddAndNotOnANoOpAdd() + { + var list = new VendorStagingList(); + int fired = 0; + list.Changed += () => fired++; + + list.Add(ItemA, 5); + Assert.Equal(1, fired); + + list.Add(0u, 5); // no-op: zero guid + Assert.Equal(1, fired); + } + + // ---- Remove: retail's amount==-1 (0xffffffff) "full removal" sentinel ---- + + [Fact] + public void RemoveWithNegativeOneAmountRemovesTheWholeEntryRegardlessOfQuantity() + { + var list = new VendorStagingList(); + list.Add(ItemA, 100); + + bool removed = list.Remove(ItemA, -1); + + Assert.True(removed); + Assert.True(list.IsEmpty); + } + + [Fact] + public void RemoveWithAnAmountAtOrAboveTheStagedQuantityRemovesTheWholeEntry() + { + var list = new VendorStagingList(); + list.Add(ItemA, 5); + + Assert.True(list.Remove(ItemA, 5)); + + Assert.True(list.IsEmpty); + } + + [Fact] + public void RemoveWithAPartialAmountDecrementsInPlace() + { + var list = new VendorStagingList(); + list.Add(ItemA, 10); + + Assert.True(list.Remove(ItemA, 3)); + + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(ItemA, entry.ItemGuid); + Assert.Equal(7, entry.Quantity); + } + + [Fact] + public void RemoveOfAnUnstagedGuidIsANoOp() + { + var list = new VendorStagingList(); + list.Add(ItemA, 5); + + bool removed = list.Remove(ItemB, -1); + + Assert.False(removed); + Assert.Single(list.Entries); + } + + [Fact] + public void RemoveOnlyTouchesTheMatchingEntry() + { + var list = new VendorStagingList(); + list.Add(ItemA, 5); + list.Add(ItemB, 9); + + list.Remove(ItemA, -1); + + VendorStagingEntry remaining = Assert.Single(list.Entries); + Assert.Equal(ItemB, remaining.ItemGuid); + Assert.Equal(9, remaining.Quantity); + } + + [Fact] + public void TryGetFindsAStagedEntryByGuid() + { + var list = new VendorStagingList(); + list.Add(ItemA, 5); + + Assert.True(list.TryGet(ItemA, out VendorStagingEntry entry)); + Assert.Equal(5, entry.Quantity); + Assert.False(list.TryGet(ItemB, out _)); + } + + [Fact] + public void ClearRemovesEveryEntryAndFiresChangedOnce() + { + var list = new VendorStagingList(); + list.Add(ItemA, 5); + list.Add(ItemB, 9); + int fired = 0; + list.Changed += () => fired++; + + list.Clear(); + + Assert.True(list.IsEmpty); + Assert.Equal(1, fired); + } + + [Fact] + public void ClearOnAnAlreadyEmptyListDoesNotFireChanged() + { + var list = new VendorStagingList(); + int fired = 0; + list.Changed += () => fired++; + + list.Clear(); + + Assert.Equal(0, fired); + } +}