fix(ui): Slice 5.4 review corrections — the dropdown renders from its authored popup, retail cost semantics, auto-select, icon overlays
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

All nine review findings closed at root (one sub-item consciously
deferred):

F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.

Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 18:26:17 +02:00
parent 9d3df5f627
commit e602f84be2
6 changed files with 696 additions and 35 deletions

View file

@ -57,7 +57,25 @@ public readonly record struct VendorShopItem(
// conditionally present (weenieFlags-gated) -- absent maps to null here,
// matching retail's own zeroed-struct default of 0 for the same case
// (see VendorPricing.PerUnitValue's <= 0 guard).
int? DescStackSize = null);
int? DescStackSize = null,
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see
// AcDream.Core.Net.Messages.PublicWeenieDescBody. Mirrors
// ClientObjectTable's ClientObject.IconUnderlayId/IconOverlayId/Effects
// naming exactly, so VendorUiController can forward them to
// _resolveIcon the same way ExternalContainerController.CreateCell does
// (item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId,
// item.Effects). 0 = "not sent", same as ClientObject's convention.
uint IconUnderlayId = 0u,
uint IconOverlayId = 0u,
uint Effects = 0u,
// Review finding F2/F3 (Slice 5.4 review): retail's NAME_PLURAL display
// (ACCWeenieObject::GetObjectName, pc:409056-409132) reads
// PublicWeenieDesc::_plural_name -- wire AcDream.Core.Net.Messages.
// PublicWeenieDescBody.PluralName. Null/empty is a real, retail-modeled
// case (no plural authored): GetObjectName falls back to the singular
// Name unchanged, not an auto-pluralized "Name+s".
string? PluralName = null);
public enum VendorStateTransitionKind
{
@ -135,10 +153,31 @@ public sealed class VendorState
Profile = profile;
Items = items;
Changed?.Invoke(new VendorTransition(
var transition = new VendorTransition(
sameVendor ? VendorStateTransitionKind.Refreshed : VendorStateTransitionKind.Opened,
previous,
vendorGuid));
vendorGuid);
// Review finding F8 (Slice 5.4 review): match Close()'s per-listener
// isolation + catch-and-log shape instead of a bare Invoke — Apply()
// is called from the SAME per-frame inbound-message-dispatch boundary
// (GameEventDispatcher.Dispatch's ApproachVendor handler) that
// Close()'s doc comment already establishes must survive a broken
// observer, and a bare Invoke lets the FIRST listener's exception
// starve every listener registered after it (e.g. a plugin panel
// wired after the retail VendorUiController).
Action<VendorTransition>? listeners = Changed;
if (listeners is not null)
{
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Apply() observer threw: {error.Message}");
}
}
}
return true;
}