feat(ui): FA4 -- fellowship page fully live

Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).

D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.

Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.

Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).

D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).

D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).

Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
  across a rebuild instead of resetting to 0 (Flush's existing
  contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
  extracted from a MountSocialPanel local function into its own
  hermetically-testable class; now shared by Friends/Squelch/
  Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
  UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
  a hardcoded literal, matching ConfigOptionsPageController's own OP6
  precedent.
- The Fellowship roster path never advances its revision latch on a
  partial resolver failure until the NEXT real membership change --
  never a per-frame retry loop.

Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.

App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 04:40:53 +02:00
parent 357d203202
commit 5bdd0528f1
16 changed files with 1941 additions and 114 deletions

View file

@ -36,15 +36,20 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x10000517</c>
/// authors a direct sibling scrollbar, <c>0x10000518</c> — wired to
/// <see cref="UiTemplateListBox.Scroll"/> the SAME way every other
/// <c>UiTemplateListBox</c> consumer wires its own scrollbar
/// (<c>CharacterOptionsPageController</c>/<c>ChatOptionsPageController</c>/
/// <c>ConfigOptionsPageController</c>/<c>KeyboardConfigController</c>).
/// Without it the list has NO wheel fallback (<see cref="UiScrollablePanel"/>
/// has no wheel handler) and is completely unreachable past the box's
/// visible extent.
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
/// ListBox <c>0x10000517</c> authors a direct sibling scrollbar,
/// <c>0x10000518</c> — wired to <see cref="UiTemplateListBox.Scroll"/> the
/// SAME way every other <c>UiTemplateListBox</c> consumer wires its own
/// scrollbar. Resolved via the built <see cref="UiTemplateListBox.ScrollbarElementId"/>
/// (dat property <c>0x72</c>, populated by <see cref="ElementReader"/>)
/// rather than the hardcoded literal a prior revision of this file used —
/// same "prefer the DAT field over a duplicated constant" fix
/// <c>ConfigOptionsPageController.Bind</c> already applied (OP6 review N2,
/// <c>feedback_prefer_dat_field_over_geometry</c>), scoped to this page's
/// own subtree so a flat lookup can't grab a same-id sibling on another
/// page. Without it the list has NO wheel fallback
/// (<see cref="UiScrollablePanel"/> has no wheel handler) and is completely
/// unreachable past the box's visible extent.
/// </para>
///
/// <para>
@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
private const uint ScrollbarElementId = 0x10000518u;
private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends;
private long _lastRevision = long.MinValue;
@ -95,11 +97,15 @@ public sealed class SocialFriendsPageController
}
listBox.TemplateResolver = templateResolver;
if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
uint scrollbarElementId = listBox.ScrollbarElementId;
UiElement? scrollbarElement = scrollbarElementId == 0
? null
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
$"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} "
$"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Friends list will not scroll.");
var controller = new SocialFriendsPageController(listBox, friends);