feat(ui): double-click-to-buy (AP-171, user-approved) + #353 toolbar text fixes — authored right-justify and two-line name wrap (Fable)
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

Double-clicking a vendor shop item now buys through the Buy button's
exact quantity/price path — retail has NO double-click-to-buy (the
named table sweep's negative evidence stands); the user chose the
addition explicitly and AP-171 records it.

#353 (pre-existing, user-reported): the stack-count entry is AUTHORED
HJustify=2 — right-justified flush against the slider on its own row —
and UiField already supported RightAligned; nobody had honored the
authored value. The name element is AUTHORED two lines tall (H=31,
W=140): long names now word-wrap at the authored pixel width onto a
second centered row via two stacked one-line labels reusing the
existing centered draw path (WrapNameTwoLines: greedy word break, no
hyphenation, second row clips like retail).

Ten SelectedObjectController structure tests updated from
single-label to first-label access. Lesson re-learned the hard way:
the first "green" run used a stale TEST assembly (only the App
project had been rebuilt) — the clean-room caught it, per
feedback_stale_build_artifacts. Full App 4,329/3 and Core 4,381/1
verified green on properly rebuilt assemblies; the one transient
Core Release failure did not reproduce and is noted on #351.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 17:42:02 +02:00
parent 1688863366
commit d674b99f56
5 changed files with 113 additions and 28 deletions

View file

@ -24,6 +24,21 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #353 — Toolbar selected-object text: count field ignores authored HJustify; name field does not wrap to its authored two lines
**Status:** FIXED 2026-08-08 pending the user's look (RightAligned on the authored HJustify=2 entry; two stacked centered one-line labels wrapping at the authored 140 px via WrapNameTwoLines).
User gate findings (pre-existing, not vendor-introduced). The authored
toolbar layout 0x21000016 is decisive: the stack-count entry 0x100001A3
is X=0 Y=13 W=50 H=14 **HJustify=2 (right)** — flush against the slider
0x100001A4 at X=50 Y=13 W=90 H=14, same row — but `UiField` has no
justify support, so the number renders at the left edge (the user's
screenshot). The name field 0x100001A2 is W=140 **H=31 (two lines)**;
`UiField` already has WrappedLine machinery but the name widget renders
one line, so long names overflow instead of breaking at the authored
140 px. Fix: honor HJustify in UiField (right-justify the text run) and
engage two-line wrap for the name element per its authored extent —
wrap threshold is the authored PIXEL width, not a character count.
## #352 — Vendor range-watcher cylinder metric: discriminating unit test deferred
**Status:** OPEN (filed 2026-08-08). The EnforceRange cylinder-gap fix

View file

@ -298,6 +298,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` |
| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) |
| AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` |
| AP-171 | **Filed 2026-08-08 (user-approved modernization).** Double-clicking a vendor shop item buys it (select + the Buy button's exact quantity/price path). Retail has NO double-click-to-buy — the full named function table was swept at the Slice 6 research and the user chose the addition explicitly after being told. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | Deliberate QoL divergence, user-directed; trivially removable. | None — additive input affordance; the single-click and Buy-button paths are unchanged. | User direction 2026-08-08 ("When I double click, I should buy it") |
| ~~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` |

View file

@ -176,6 +176,11 @@ public sealed class SelectedObjectController : IRetainedPanelController
if (_stackSizeEntry is not null)
{
_stackSizeEntry.Visible = false;
// #353: the entry is AUTHORED HJustify=2 (right) at X=0 W=50,
// flush against the slider at X=50 on the same row — the count
// reads right-adjacent to the bar, retail's look. UiField
// already supports it; the importer does not carry HJustify.
_stackSizeEntry.RightAligned = true;
_stackSizeEntry.Selectable = true;
_stackSizeEntry.ClearOnSubmit = false;
_stackSizeEntry.RecordHistory = false;
@ -205,26 +210,46 @@ public sealed class SelectedObjectController : IRetainedPanelController
if (_name is not null)
{
_name.ZOrder = NameZOrderOnTop;
var label = new UiText
// #353: the name element is AUTHORED two lines tall (H=31 at
// W=140) — a long name wraps at the authored PIXEL width onto a
// second row instead of overflowing (user-verified retail
// behavior). Two stacked centered one-line labels reuse the
// existing centered draw path; the second draws nothing when
// the name fits.
float nameWidth = _name.Width;
var wrapFont = datFont;
Func<int, UiText.Line[]> lineFor = index =>
{
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true,
OneLine = true,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{
var n = _currentName;
return string.IsNullOrEmpty(n)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(n, NameColor) };
},
var n = _currentName;
if (string.IsNullOrEmpty(n))
return Array.Empty<UiText.Line>();
(string first, string second) = WrapNameTwoLines(n, nameWidth, wrapFont);
string text = index == 0 ? first : second;
return text.Length == 0
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(text, NameColor) };
};
_name.AddChild(label);
for (int lineIndex = 0; lineIndex < 2; lineIndex++)
{
int captured = lineIndex;
var label = new UiText
{
Left = 0f,
Top = captured * NameBandHeight,
Width = _name.Width,
Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true,
OneLine = true,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () => lineFor(captured),
};
_name.AddChild(label);
}
}
// Register the handlers LAST so the initial state is fully set up first.
@ -454,6 +479,37 @@ public sealed class SelectedObjectController : IRetainedPanelController
_manaMeter.Visible = true;
}
/// <summary>
/// #353: greedy word wrap of the selected-object name into at most two
/// lines at the authored pixel width. A single word longer than the
/// width stays unbroken on its line (retail does not hyphenate). The
/// second line carries everything remaining — the authored element is
/// exactly two lines tall, so anything longer simply clips like retail.
/// </summary>
internal static (string First, string Second) WrapNameTwoLines(
string name,
float width,
UiDatFont? font)
{
if (font is null || font.MeasureWidth(name) <= width)
return (name, string.Empty);
int breakAt = -1;
for (int i = 0; i < name.Length; i++)
{
if (name[i] != ' ')
continue;
if (font.MeasureWidth(name[..i]) <= width)
breakAt = i;
else
break;
}
if (breakAt <= 0)
return (name, string.Empty);
return (name[..breakAt], name[(breakAt + 1)..].TrimStart());
}
private void CommitStackEntry(string text)
=> _splitQuantity.SetFromText(text);

View file

@ -1107,6 +1107,19 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
cell.Selected = item.ItemGuid == selectedGuid;
VendorShopItem captured = item;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
// AP-171: double-click buys the item — a DELIBERATE,
// user-approved modernization. Retail has NO
// double-click-to-buy anywhere in the named function
// table (negative evidence recorded at the Slice 6
// research); the user requested it explicitly
// 2026-08-08 after being told so. Select-then-buy so
// the quantity/price path is identical to the Buy
// button's.
cell.DoubleClicked = () =>
{
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
BuySelectedItem();
};
_itemList.AddItem(cell);
}
}

View file

@ -152,7 +152,7 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible, "health meter must be Visible=false immediately after Bind");
var textChild = nameEl.Children.OfType<UiText>().SingleOrDefault();
var textChild = nameEl.Children.OfType<UiText>().FirstOrDefault();
Assert.NotNull(textChild);
Assert.True(textChild!.Centered, "name UiText must be Centered");
Assert.True(textChild.ClickThrough, "name UiText must be ClickThrough");
@ -171,7 +171,7 @@ public class SelectedObjectControllerTests
var (layout, nameEl, _, _) = FakeLayout();
new Harness().Bind(layout);
var textChild = nameEl.Children.OfType<UiText>().Single();
var textChild = nameEl.Children.OfType<UiText>().First();
Assert.Empty(textChild.LinesProvider());
}
@ -202,7 +202,7 @@ public class SelectedObjectControllerTests
SelectionChangeReason.CombatTargetDied);
Assert.Equal(Replacement, h.Selection.SelectedObjectId);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
var lines = nameEl.Children.OfType<UiText>().First().LinesProvider();
Assert.Single(lines);
Assert.Equal("Drudge Prowler", lines[0].Text);
}
@ -233,7 +233,7 @@ public class SelectedObjectControllerTests
Assert.Equal(Guid, h.QueryHealthCalls[0]);
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
var lines = nameEl.Children.OfType<UiText>().First().LinesProvider();
Assert.Single(lines);
Assert.Equal(ExpectedName, lines[0].Text);
Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color);
@ -338,7 +338,7 @@ public class SelectedObjectControllerTests
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal(17u, h.SplitQuantity.Maximum);
Assert.Equal(1f, slider.ScalarPosition);
Assert.Equal("17 Healing Kits", nameEl.Children.OfType<UiText>().Single().LinesProvider().Single().Text);
Assert.Equal("17 Healing Kits", nameEl.Children.OfType<UiText>().First().LinesProvider().Single().Text);
slider.SetScalarPosition(0.5f);
slider.ScalarChanged!(0.5f);
@ -405,7 +405,7 @@ public class SelectedObjectControllerTests
Assert.Empty(h.QueryHealthCalls);
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
var lines = nameEl.Children.OfType<UiText>().First().LinesProvider();
Assert.Single(lines);
Assert.Equal(ExpectedName, lines[0].Text);
}
@ -432,7 +432,7 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect");
Assert.Equal("", overlayEl.ActiveState);
Assert.Empty(nameEl.Children.OfType<UiText>().Single().LinesProvider());
Assert.Empty(nameEl.Children.OfType<UiText>().First().LinesProvider());
Assert.Equal(new[] { Guid, 0u }, h.QueryHealthCalls);
}
@ -461,7 +461,7 @@ public class SelectedObjectControllerTests
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
Assert.Equal(new[] { GuidA, 0u }, h.QueryHealthCalls);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
var lines = nameEl.Children.OfType<UiText>().First().LinesProvider();
Assert.Single(lines);
Assert.Equal("Chest", lines[0].Text);
}
@ -870,7 +870,7 @@ public class SelectedObjectControllerTests
// Retail's "{count} {plural}" toolbar label.
var nameElement = layout.FindElement(SelectedObjectController.NameId);
Assert.NotNull(nameElement);
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
UiText nameLabel = nameElement!.Children.OfType<UiText>().First(); // #353: two stacked line labels now
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));
Assert.Equal("100 Lead Scarabs", renderedName);
@ -942,7 +942,7 @@ public class SelectedObjectControllerTests
var nameElement = layout.FindElement(SelectedObjectController.NameId);
Assert.NotNull(nameElement);
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
UiText nameLabel = nameElement!.Children.OfType<UiText>().First(); // #353: two stacked line labels now
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));