feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
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
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
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 <noreply@anthropic.com>
This commit is contained in:
parent
ab3146ba88
commit
92ea3977b6
18 changed files with 2578 additions and 78 deletions
137
src/AcDream.Core/Items/VendorSellAcceptability.cs
Normal file
137
src/AcDream.Core/Items/VendorSellAcceptability.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="VendorSellAcceptability.Evaluate"/> — retail
|
||||
/// <c>VendorSellUI::DragItemAcceptable</c>'s (<c>pc:201195-201307</c>,
|
||||
/// <c>0x004c20c0</c>) full gate chain: ownership, the non-empty-container
|
||||
/// bypass, then <c>VendorProfile::InqAcceptability</c>
|
||||
/// (<c>pc:484768-484797</c>, <c>0x005d1a90</c>).
|
||||
/// </summary>
|
||||
public enum VendorSellRejection
|
||||
{
|
||||
/// <summary>Acceptable — stage the drop.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary><c>ACCWeenieObject::IsOwnedByPlayer(esi) == 0</c> (<c>pc:201206</c>).</summary>
|
||||
NotOwnedByPlayer,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
|
||||
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
|
||||
/// the generic case in practice, since a genuine bitmask is almost never
|
||||
/// literally 1-4 (Slice 6b/6c research doc's open question #3).
|
||||
/// </summary>
|
||||
WrongType,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> literally returned 1 — retail's
|
||||
/// <c>DragItemAcceptable</c> switch has a case for it
|
||||
/// (<c>pc:201259-201267</c>), but nothing in <c>InqAcceptability</c>'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.
|
||||
/// </summary>
|
||||
CannotBeSoldHere,
|
||||
|
||||
/// <summary><c>InqAcceptability</c> == 2: per-unit value is exactly zero (<c>pc:005d1ac3</c>).</summary>
|
||||
NoValue,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
|
||||
/// <c>max_value != -1 && value > max_value</c>.
|
||||
/// </summary>
|
||||
TooValuable,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> == 3: <c>min_value != -1 && value < min_value</c>
|
||||
/// (<c>pc:005d1af2</c>).
|
||||
/// </summary>
|
||||
TooCheap,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of <c>VendorSellUI::DragItemAcceptable</c> +
|
||||
/// <c>VendorProfile::InqAcceptability</c> — gates a Selling-tab drop AND its
|
||||
/// hover-preview cursor (the <c>silent</c> distinction is the caller's job:
|
||||
/// <see cref="Evaluate"/> always computes the same outcome, callers choose
|
||||
/// whether to surface <see cref="MessageFor"/>).
|
||||
/// </summary>
|
||||
public static class VendorSellAcceptability
|
||||
{
|
||||
/// <summary>Retail's -1/0xffffffff "no limit" sentinel for <c>min_value</c>/<c>max_value</c>.</summary>
|
||||
public const uint NoLimit = uint.MaxValue;
|
||||
|
||||
/// <param name="ownedByPlayer"><c>ACCWeenieObject::IsOwnedByPlayer(esi)</c>.</param>
|
||||
/// <param name="containedItemCount">
|
||||
/// <c>ACCWeenieObject::GetNumContainedItems(esi)</c> — a non-empty
|
||||
/// container (a bag with stuff in it) always passes, bypassing the
|
||||
/// type/value filter entirely (<c>pc:201229-201233</c>).
|
||||
/// </param>
|
||||
/// <param name="itemTypeMask">The dragged item's own <c>PublicWeenieDesc::_type</c>.</param>
|
||||
/// <param name="perUnitValue">
|
||||
/// The dragged item's per-unit value — <see cref="VendorPricing.PerUnitValue"/>
|
||||
/// applied to its own <c>Value</c>/<c>StackSize</c>, matching
|
||||
/// <c>InqAcceptability</c>'s own <c>_stackSize > 0 ? _value/_stackSize : _value</c>
|
||||
/// division (<c>pc:005d1ab2-005d1ab6</c>).
|
||||
/// </param>
|
||||
/// <param name="merchandiseItemTypes">The vendor's <c>VendorShopProfile.MerchandiseItemTypes</c>.</param>
|
||||
/// <param name="merchandiseMinValue">
|
||||
/// The vendor's <c>VendorShopProfile.MerchandiseMinValue</c> —
|
||||
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
||||
/// </param>
|
||||
/// <param name="merchandiseMaxValue">
|
||||
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
|
||||
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact rejection strings, read directly out of the decompiled
|
||||
/// binary's data segment (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>,
|
||||
/// addresses <c>0x007b51a8</c>/<c>0x007b51e0</c>/<c>0x007b5230</c>/
|
||||
/// <c>0x007b5278</c>/<c>0x007b52cc</c>/<c>0x007b5308</c>) resolving the
|
||||
/// truncated "…" citations the Slice 6b/6c research doc quoted. Delivered
|
||||
/// via <c>ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)</c> — the SAME
|
||||
/// system-message notice channel (<c>0x1a</c>) every other retail-ported
|
||||
/// transient string in this codebase already uses.
|
||||
/// </summary>
|
||||
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",
|
||||
};
|
||||
}
|
||||
98
src/AcDream.Core/Items/VendorStagingList.cs
Normal file
98
src/AcDream.Core/Items/VendorStagingList.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>One staged Buying/Selling-tab row — a shop/pack item guid plus a staged quantity.</summary>
|
||||
public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity);
|
||||
|
||||
/// <summary>
|
||||
/// Client-local staging list backing retail's <c>gmVendorUI::m_buyList</c>/
|
||||
/// <c>m_sellList</c> (both a <c>PackableList<ItemProfile></c>) — nothing here
|
||||
/// ever reaches the wire; only a batched Buy All/Sell All send reads
|
||||
/// <see cref="Entries"/> to build one wire payload. Shared by the Buying tab
|
||||
/// (<c>VendorBuyUI</c>) and the Selling tab (<c>VendorSellUI</c>) — retail keeps
|
||||
/// two nearly-parallel classes over the SAME <c>PackableList</c> 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.
|
||||
/// </summary>
|
||||
public sealed class VendorStagingList
|
||||
{
|
||||
private readonly List<VendorStagingEntry> _entries = new();
|
||||
|
||||
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
|
||||
public bool IsEmpty => _entries.Count == 0;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
|
||||
/// (Slice 6b/6c research doc §Q3): stages <paramref name="quantity"/>
|
||||
/// units of <paramref name="itemGuid"/>. 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 <c>RemoveProfileFromList</c> looks up an entry BY GUID
|
||||
/// (a single match), which only holds if <c>AddToBuyList</c> 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmVendorUI::RemoveProfileFromList</c> (<c>pc:200497-200537</c>,
|
||||
/// <c>0x004c1260</c>): <paramref name="amount"/> == -1 (retail's
|
||||
/// <c>0xffffffff</c>) or >= the staged quantity removes the WHOLE
|
||||
/// entry; otherwise decrements it in place. Returns <see langword="false"/>
|
||||
/// with no effect for an unstaged guid.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Port of the unconditional <c>PackableList<ItemProfile>::Flush</c> 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).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
return;
|
||||
_entries.Clear();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue