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

@ -14,6 +14,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Content;
using AcDream.Core.Input;
@ -225,12 +226,42 @@ public sealed record OptionsRuntimeBindings(
/// the panel's read-only rows need the actual entries, same access shape
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
/// already exposes for every other Communication-owned consumer).
///
/// <para>
/// Campaign FA slice FA4 adds the Fellowship page's write surface:
/// <see cref="FellowshipMembers"/> (the roster <see cref="IRuntimeFellowshipView"/>
/// alone cannot enumerate — see that interface's own FA4 addendum) and the
/// seven generation-gated commands, all routed through
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
/// (the SAME late-bound, race-safe seam <c>AddShortcut</c>/<c>Advance</c>
/// already use) rather than a raw <c>WorldSession.SendXxx</c> call —
/// <c>FellowshipQuit</c> in particular MUST go through
/// <c>IRuntimeFellowshipCommands.Quit</c> because the retail leader
/// hand-off rule (<c>0x0290</c> before <c>0x00A3</c>) lives in
/// <c>RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit</c> +
/// <c>DirectGameRuntimeCommandAdapter.Quit</c>/
/// <c>CurrentGameRuntimeCommandAdapter.Quit</c>, not in
/// <c>WorldSession</c> itself — bypassing the command layer would silently
/// drop that behavior. <see cref="Selection"/> is the Recruit target source
/// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection).
/// </para>
/// </summary>
public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
AcDream.Core.Social.FriendsState Friends,
AcDream.Core.Social.SquelchState Squelch);
AcDream.Core.Social.SquelchState Squelch,
Func<IEnumerable<AcDream.Runtime.RuntimeFellowMemberSnapshot>> FellowshipMembers,
Func<string, bool, RuntimeCommandResult> FellowshipCreate,
Func<uint, RuntimeCommandResult> FellowshipRecruit,
Func<uint, RuntimeCommandResult> FellowshipDismiss,
Func<bool, RuntimeCommandResult> FellowshipQuit,
Func<uint, RuntimeCommandResult> FellowshipAssignLeader,
Func<bool, RuntimeCommandResult> FellowshipSetOpen,
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -631,8 +662,35 @@ public sealed class RetailUiRuntime : IDisposable
}
}
/// <summary>
/// Campaign FA slice FA4, D6: a fellowship invite (<c>ConfirmationType.
/// Fellowship</c>, 4) is intercepted BEFORE it ever reaches the generic
/// <see cref="GameplayConfirmationController"/> dialog — retail's
/// <c>Fellowship.cs:121</c>-equivalent client-side mirror:
/// <c>IgnoreFellowshipRequests</c> auto-declines, <c>FellowshipAutoAcceptRequests</c>
/// auto-accepts, and the two are mutually exclusive
/// (<c>RuntimeCharacterState.TrySetOption</c>, already ported) so at
/// most one fires. Neither bit set → falls through to the generic
/// controller exactly as it already did before this slice (it already
/// matches the constants — see D6/lane A §5.3).
/// </summary>
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
{
if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship
&& TryAutoRespondToFellowshipInvite(request))
return true;
return _gameplayConfirmationController?.HandleRequest(request) == true;
}
private bool TryAutoRespondToFellowshipInvite(GameEvents.CharacterConfirmationRequest request)
{
bool ignore = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.IgnoreFellowshipRequests);
bool autoAccept = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.FellowshipAutoAcceptRequests);
if (!ignore && !autoAccept) return false;
_bindings.Confirmations.SendResponse(request.Type, request.ContextId, autoAccept);
return true;
}
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
@ -2641,20 +2699,19 @@ public sealed class RetailUiRuntime : IDisposable
// resolvers read the shared, non-thread-safe DatCollection) because
// each row needs its OWN UiElement instance — but the expensive
// per-row DAT tree walk is gone after the first resolve.
var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>();
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
// FA4 carry-forward 2: the caching logic itself now lives in the
// hermetically-testable Layout.RowTemplateResolver (see its own
// class doc) — this closure supplies only the two DAT-touching
// primitives (import, build) and keeps the shared lock at the
// production call site. Feeds Friends/Squelch's row families (FA3)
// AND Fellowship's (FA4, 0x21000030/0x10000281) through the SAME
// instance/cache — all three are UiTemplateListBox row families on
// the one social panel.
var rowTemplates = new Layout.RowTemplateResolver(
(layoutId, elementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, layoutId, elementId),
info =>
{
var key = (templateLayoutId, templateElementId);
if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info))
{
info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
rowTemplateCache[key] = info;
}
if (info is null) return null;
var strings = new DatStringResolver(_bindings.Assets.Dats);
return LayoutImporter.Build(
info,
@ -2662,19 +2719,53 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
}
});
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
}
// Shared across every bind-time ResolveString call below (2
// captions + 4x2 checkbox label/tooltip) so its internal
// per-table-id cache actually helps rather than re-reading the same
// StringTable object ten times.
var fellowshipStrings = new DatStringResolver(_bindings.Assets.Dats);
var callbacks = new Layout.SocialPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
FellowshipSnapshot: _bindings.Social.FellowshipSnapshot,
Fellowship: new Layout.SocialFellowshipPageController.Bindings(
Snapshot: _bindings.Social.FellowshipSnapshot,
Members: _bindings.Social.FellowshipMembers,
TemplateResolver: TemplateResolver,
Create: _bindings.Social.FellowshipCreate,
Recruit: _bindings.Social.FellowshipRecruit,
Dismiss: _bindings.Social.FellowshipDismiss,
Quit: _bindings.Social.FellowshipQuit,
AssignLeader: _bindings.Social.FellowshipAssignLeader,
SetOpen: _bindings.Social.FellowshipSetOpen,
SetPanelOpen: _bindings.Social.FellowshipSetPanelOpen,
Selection: _bindings.Social.Selection,
LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);
Layout.SocialPanelController? controller =
Layout.SocialPanelController.Bind(layout, callbacks);
// A second DatLock scope (MountOptionsPanel's own precedent, above):
// SocialFellowshipPageController.Bind resolves the Open/Close
// caption pair and all four checkbox label/tooltip strings at BIND
// TIME (never per-tick — see that class's own doc on why), and
// Friends/Squelch/Fellowship's row-building all reach the SAME
// DatLock through TemplateResolver above (re-entrant on this
// thread — Monitor.Enter is safe to re-acquire recursively).
Layout.SocialPanelController? controller;
lock (_bindings.Assets.DatLock)
controller = Layout.SocialPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");