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

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:
Erik 2026-08-08 11:43:11 +02:00
parent ab3146ba88
commit 92ea3977b6
18 changed files with 2578 additions and 78 deletions

View file

@ -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<VendorShopItem>());
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<UiText>());
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));
var slider = Assert.IsType<UiScrollbar>(
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();
}
}