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

@ -1,52 +1,230 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap —
/// the ONLY behavior this SHELL slice owns for this page (roster rows,
/// vitals, create dialog, and the option row un-dims are FA4 scope).
/// Campaign FA slice FA4: the Fellowship page fully live — roster rows, the
/// <c>0x00A6</c> panel-open/vitals-stream gate (D4), the inline create flow,
/// member actions + their button-enable rules, the four option checkboxes,
/// and the open/close caption swap. FA3 shipped only the empty/full frame
/// swap (see that slice's own doc, still the authority for the frame-pair
/// containment finding).
///
/// <para>
/// Retail <c>gmFellowshipUI::Update @0x0048F440</c>
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
/// <c>m_pNotInAFellowshipFrame</c> (<c>0x1000026B</c>) shows and
/// <c>m_pInAFellowshipFrame</c> (<c>0x10000275</c>) hides when
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
/// button, and all FOUR visible option checkboxes (Ignore Fellowship
/// Requests / Auto-Accept Requests / Share XP / Share Loot —
/// <c>0x10000270</c><c>0x10000273</c>, fix-round mechanism SF-4); <c>0x10000275</c> holds
/// the roster list and all six member-management buttons. A single
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
/// unknown U6).
/// <b>Roster rebuild discipline — FA3 carry-forward 1.</b>
/// <see cref="RuntimeFellowshipState"/> bumps ONE monotonic revision for
/// every mutation, including a pure vitals refresh
/// (<c>0x02C0</c> with the same member set — lane B §2.6/§4.5, the
/// <c>0x00A6</c>-gated stream this same slice turns on). A naive
/// "revision changed → <c>Flush</c> + rebuild" binding (the Friends/Squelch
/// shape) would therefore tear down and rebuild the whole ListBox on every
/// incoming vitals tick while the panel is open — resetting the user's
/// scroll position to the top every time (<see cref="UiTemplateListBox.Flush"/>
/// zeroes the scroll offset) and re-resolving nine DAT row templates for no
/// structural reason. <see cref="RefreshRoster"/> instead diffs the member
/// GUID SET: when it is unchanged, existing rows are updated IN PLACE
/// (<see cref="UpdateRow"/> — no ListBox mutation at all, so scroll position
/// is untouched by construction); only an actual join/leave/disband
/// difference triggers <see cref="UiTemplateListBox.FlushPreservingScroll"/>
/// (the widget-level half of the same carry-forward, for the rarer
/// structural-change case).
/// </para>
///
/// <para>
/// <b>Row-text StringInfo gap (register row filed this slice).</b> Every
/// row field beyond the bare name is a retail <c>StringInfo</c> template
/// with variable substitution (<c>ID_Fellowship_FellowStats</c> + vars
/// <c>ID_Level</c>/<c>ID_Experience</c>; <c>…FellowHealthStatus</c>/
/// <c>…StaminaStatus</c>/<c>…ManaStatus</c> + vars <c>ID_Cur</c>/<c>ID_Max</c>
/// — docs/research/2026-08-11-fa-panel-structure.md §3.1/§4.1). acdream has
/// not ported retail's variable-substitution engine
/// (<c>StringInfo::InqString</c> → <c>StringTableMetaLanguage::UnescapeString</c>
/// — a cross-cutting UI-string prerequisite, not fellowship-specific) so
/// this controller renders the numeric CONTENT of those fields as plain
/// composites (<c>"{level} {pct}%"</c>, <c>"{cur}/{max}"</c>) rather than
/// resolving retail's exact surrounding sentence — no invented English
/// words are added, only the retail-authored NUMBERS. Same disposition the
/// pre-Campaign-OP Character window recorded for the identical gap
/// (docs/research/2026-06-25-character-window-faithful-spec.md: "NOT yet
/// ported — current controller uses canonical AC labels").
/// </para>
///
/// <para>
/// <b>Leader marker.</b> Lane A's row-template inventory names no dedicated
/// "this fellow is the leader" element (the 8-child row is name / stats /
/// three meter+text pairs, nothing else) — retail may simply not mark
/// leadership in the row at all. Lacking a decompiled anchor either way,
/// this controller tints the leader's name text a distinct gold
/// (<see cref="LeaderNameColor"/>) as a minimal, clearly-adaptive visual
/// cue rather than inventing a DAT mechanism that was not found.
/// </para>
/// </summary>
public sealed class SocialFellowshipPageController
{
// ── Page-level element ids (docs/research/2026-08-11-fa-panel-structure.md §3.1) ──
private const uint ListBoxId = 0x10000279u;
private const uint NameEntryBoxId = 0x1000026Fu;
private const uint CreateButtonId = 0x10000274u;
private const uint FellowshipNameTextId = 0x10000276u;
private const uint LeaderButtonId = 0x1000027Bu;
private const uint QuitButtonId = 0x1000027Cu;
private const uint OpenButtonId = 0x1000027Du;
private const uint RecruitButtonId = 0x1000027Eu;
private const uint DismissButtonId = 0x1000027Fu;
private const uint DisbandButtonId = 0x10000280u;
private const uint IgnoreRequestsCheckboxId = 0x10000270u;
private const uint AutoAcceptCheckboxId = 0x10000271u;
private const uint ShareXpCheckboxId = 0x10000272u;
private const uint ShareLootCheckboxId = 0x10000273u;
// ── Row-template element ids (same doc, the 8-part fellow row) ──
private const uint RowNameTextId = 0x10000283u;
private const uint RowStatsTextId = 0x10000284u;
private const uint RowHealthMeterId = 0x10000285u;
private const uint RowStaminaMeterId = 0x10000287u;
private const uint RowManaMeterId = 0x10000289u;
// ── String tables (§4.3): checkbox label/tooltip pairs resolve in
// 0x23000003 (matching CharacterOptionsPageController.StringTableId —
// same PlayerOption ids, same ID_PlayerOption_<Name> keys, a SECOND
// authored checkbox instance for the SAME retail strings); the open/
// close button caption resolves in 0x23000001 (ID_Fellowship_*).
private const uint OptionStringTableId = 0x23000003u;
private const uint FellowshipStringTableId = 0x23000001u;
/// <summary>Retail's byte-decoded even-split share table (lane B §7.2),
/// index 0 = 1 fellow. Index 8 (9 fellows) is 0.3111111 — ACE currently
/// grants .3 there; see the D5 register row this slice files.</summary>
private static readonly float[] EvenSplitPercentTable =
[1.0f, 0.75f, 0.6f, 0.55f, 0.5f, 0.45f, 0.4f, 0.35f, 0.3111111f, 0.28f];
/// <summary><c>Fellowship::IsFull @0x005B9070</c>, byte-verified
/// <c>_currNum &gt;= 9</c> (lane B §7.1).</summary>
private const int MaxFellowshipSize = 9;
private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f);
private static readonly Vector4 MemberNameColor = Vector4.One;
/// <summary>The live read/write seam this page binds to — Runtime's
/// FA2 fellowship owner (snapshot + member enumeration), the seven
/// generation-gated commands routed through
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>,
/// the world-selection state (Recruit's target source), the local
/// player's own guid, the shared character-option seam the four
/// checkboxes bind through (the SAME storage
/// <c>CharacterOptionsPageController</c>'s Character tab uses — both
/// surfaces are live views of one value, lane A §6.5), and a DAT string
/// resolver.</summary>
public sealed record Bindings(
Func<RuntimeFellowshipSnapshot> Snapshot,
Func<IEnumerable<RuntimeFellowMemberSnapshot>> Members,
Func<uint, uint, UiElement?> TemplateResolver,
Func<string, bool, RuntimeCommandResult> Create,
Func<uint, RuntimeCommandResult> Recruit,
Func<uint, RuntimeCommandResult> Dismiss,
Func<bool, RuntimeCommandResult> Quit,
Func<uint, RuntimeCommandResult> AssignLeader,
Func<bool, RuntimeCommandResult> SetOpen,
Func<bool, RuntimeCommandResult> SetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid,
Func<CharacterOptionId, bool> CurrentCharacterOption,
Action<CharacterOptionId, bool> SetCharacterOption,
Func<uint, uint, string?> ResolveString);
private readonly record struct FellowRowWidgets(
UiText? Name,
UiText? Stats,
UiMeter? Health,
UiMeter? Stamina,
UiMeter? Mana);
private readonly UiElement _notInFellowshipFrame;
private readonly UiElement _inFellowshipFrame;
private readonly Func<RuntimeFellowshipSnapshot> _snapshot;
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _listBox;
private readonly UiField? _nameField;
private readonly UiButton? _createButton;
private readonly UiText? _fellowshipNameText;
private readonly UiButton? _leaderButton;
private readonly UiButton? _quitButton;
private readonly UiButton? _openButton;
private readonly UiButton? _recruitButton;
private readonly UiButton? _dismissButton;
private readonly UiButton? _disbandButton;
private readonly UiButton? _ignoreRequestsCheckbox;
private readonly UiButton? _autoAcceptCheckbox;
private readonly UiButton? _shareXpCheckbox;
private readonly UiButton? _shareLootCheckbox;
private readonly Dictionary<uint, FellowRowWidgets> _rows = new();
private uint _selectedFellowGuid;
private long _lastRosterRevision = long.MinValue;
private bool? _lastOpenState;
private bool _pageVisible;
/// <summary>Resolved ONCE at <see cref="Bind"/> (not per-tick — every
/// other <c>DatStringResolver</c> consumer in this codebase is
/// bind-time-only; <c>DatCollection</c> is not safe to touch
/// unprotected from the render/tick loop, per
/// claude-memory/feedback_phase_a1_hotfix_saga.md). Null when the
/// string did not resolve — the button then keeps whatever caption the
/// import gave it rather than showing invented English.</summary>
private readonly string? _openCaption;
private readonly string? _closeCaption;
private SocialFellowshipPageController(
UiElement notInFellowshipFrame,
UiElement inFellowshipFrame,
Func<RuntimeFellowshipSnapshot> snapshot)
Bindings bindings,
UiTemplateListBox? listBox,
UiField? nameField,
UiButton? createButton,
UiText? fellowshipNameText,
UiButton? leaderButton,
UiButton? quitButton,
UiButton? openButton,
UiButton? recruitButton,
UiButton? dismissButton,
UiButton? disbandButton,
UiButton? ignoreRequestsCheckbox,
UiButton? autoAcceptCheckbox,
UiButton? shareXpCheckbox,
UiButton? shareLootCheckbox,
string? openCaption,
string? closeCaption)
{
_notInFellowshipFrame = notInFellowshipFrame;
_inFellowshipFrame = inFellowshipFrame;
_snapshot = snapshot;
_bindings = bindings;
_listBox = listBox;
_nameField = nameField;
_createButton = createButton;
_fellowshipNameText = fellowshipNameText;
_leaderButton = leaderButton;
_quitButton = quitButton;
_openButton = openButton;
_recruitButton = recruitButton;
_dismissButton = dismissButton;
_disbandButton = disbandButton;
_ignoreRequestsCheckbox = ignoreRequestsCheckbox;
_autoAcceptCheckbox = autoAcceptCheckbox;
_shareXpCheckbox = shareXpCheckbox;
_shareLootCheckbox = shareLootCheckbox;
_openCaption = openCaption;
_closeCaption = closeCaption;
}
public static SocialFellowshipPageController? Bind(
UiElement pageRoot,
Func<RuntimeFellowshipSnapshot> snapshot)
public static SocialFellowshipPageController? Bind(UiElement pageRoot, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
@ -58,23 +236,446 @@ public sealed class SocialFellowshipPageController
return null;
}
var controller = new SocialFellowshipPageController(notIn, inFellowship, snapshot);
UiTemplateListBox? listBox = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
if (listBox is null)
Console.WriteLine(
$"[D.2b] SocialFellowshipPageController: ListBox 0x{ListBoxId:X8} not "
+ "found — the fellowship roster will not populate.");
else
{
listBox.TemplateResolver = bindings.TemplateResolver;
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] SocialFellowshipPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the fellowship roster will not scroll.");
}
UiField? nameField = UiElement.FindDescendant(pageRoot, NameEntryBoxId) as UiField;
if (nameField is null)
Console.WriteLine(
$"[D.2b] SocialFellowshipPageController: name-entry field 0x{NameEntryBoxId:X8} "
+ "not found (or not authored Editable) — Create will not read a typed name.");
UiButton? createButton = UiElement.FindDescendant(pageRoot, CreateButtonId) as UiButton;
UiText? fellowshipNameText = UiElement.FindDescendant(pageRoot, FellowshipNameTextId) as UiText;
UiButton? leaderButton = UiElement.FindDescendant(pageRoot, LeaderButtonId) as UiButton;
UiButton? quitButton = UiElement.FindDescendant(pageRoot, QuitButtonId) as UiButton;
UiButton? openButton = UiElement.FindDescendant(pageRoot, OpenButtonId) as UiButton;
UiButton? recruitButton = UiElement.FindDescendant(pageRoot, RecruitButtonId) as UiButton;
UiButton? dismissButton = UiElement.FindDescendant(pageRoot, DismissButtonId) as UiButton;
UiButton? disbandButton = UiElement.FindDescendant(pageRoot, DisbandButtonId) as UiButton;
UiButton? ignoreRequestsCheckbox = UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton;
UiButton? autoAcceptCheckbox = UiElement.FindDescendant(pageRoot, AutoAcceptCheckboxId) as UiButton;
UiButton? shareXpCheckbox = UiElement.FindDescendant(pageRoot, ShareXpCheckboxId) as UiButton;
UiButton? shareLootCheckbox = UiElement.FindDescendant(pageRoot, ShareLootCheckboxId) as UiButton;
// Bind-time only (see the field's own doc) — gmFellowshipUI::
// UpdateButtons @0x0048E6C0 (lane A §4.1): a CLOSED fellowship
// shows "Open Fellowship", the button reads as the action.
string? openCaption = bindings.ResolveString(
FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText"));
string? closeCaption = bindings.ResolveString(
FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText"));
if (openCaption is null || closeCaption is null)
Console.WriteLine(
"[D.2b] SocialFellowshipPageController: Open/Close Fellowship button caption(s) "
+ "did not resolve — the button keeps its imported caption rather than "
+ "invented English.");
var controller = new SocialFellowshipPageController(
notIn,
inFellowship,
bindings,
listBox,
nameField,
createButton,
fellowshipNameText,
leaderButton,
quitButton,
openButton,
recruitButton,
dismissButton,
disbandButton,
ignoreRequestsCheckbox,
autoAcceptCheckbox,
shareXpCheckbox,
shareLootCheckbox,
openCaption,
closeCaption);
controller.WireButtons();
controller.WireCheckboxes();
controller.Tick();
return controller;
}
private void WireButtons()
{
if (_createButton is not null)
_createButton.OnClick = () =>
{
string name = _nameField?.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(name)) return;
bool shareXp = _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareXP);
_bindings.Create(name, shareXp);
};
if (_recruitButton is not null)
_recruitButton.OnClick = () =>
{
if (_bindings.Selection.SelectedObjectId is { } targetGuid)
_bindings.Recruit(targetGuid);
};
if (_dismissButton is not null)
_dismissButton.OnClick = () =>
{
if (_selectedFellowGuid != 0u) _bindings.Dismiss(_selectedFellowGuid);
};
if (_leaderButton is not null)
_leaderButton.OnClick = () =>
{
if (_selectedFellowGuid != 0u) _bindings.AssignLeader(_selectedFellowGuid);
};
// Quit and Disband are the same 0x00A3 opcode, flag flipped (lane B
// §2.5/§3.2). The retail leader-handoff pre-send (0x0290 before
// 0x00A3 when quitting-not-disbanding as the current leader) lives
// in RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit, applied
// by the command adapters — Quit(disband) here is the whole story.
if (_quitButton is not null)
_quitButton.OnClick = () => _bindings.Quit(false);
if (_disbandButton is not null)
_disbandButton.OnClick = () => _bindings.Quit(true);
if (_openButton is not null)
_openButton.OnClick = () =>
{
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
_bindings.SetOpen(!snapshot.IsOpen);
};
}
private void WireCheckboxes()
{
BindCheckbox(_ignoreRequestsCheckbox, CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests");
BindCheckbox(_autoAcceptCheckbox, CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests");
BindCheckbox(_shareXpCheckbox, CharacterOptionId.FellowshipShareXP, "FellowshipShareXP");
BindCheckbox(_shareLootCheckbox, CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot");
}
private void BindCheckbox(UiButton? checkbox, CharacterOptionId id, string retailName)
{
if (checkbox is null) return;
string labelKey = $"ID_PlayerOption_{retailName}";
string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey));
if (label is not null)
checkbox.Label = label;
else
Console.WriteLine(
$"[D.2b] SocialFellowshipPageController: label '{labelKey}' did not resolve — "
+ "checkbox renders with no caption rather than invented English.");
string? tooltip = _bindings.ResolveString(
OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
if (tooltip is not null)
checkbox.TooltipText = tooltip;
checkbox.OnClick = () =>
{
bool next = !checkbox.Selected;
checkbox.Selected = next;
_bindings.SetCharacterOption(id, next);
};
}
/// <summary>
/// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
/// bool writes) — called every frame from
/// <see cref="SocialPanelController.Tick"/> rather than gated on
/// page-shown/revision, since <see cref="UiElement.Visible"/> has no
/// data-driven provider mechanism of its own (unlike <see cref="UiText.LinesProvider"/>,
/// which is polled by the render loop already).
/// Re-reads live state and applies retail's frame swap, roster diff,
/// button-enable rules, and checkbox seeding. Called every frame from
/// <see cref="SocialPanelController.Tick"/>, matching every other
/// retained panel's empty-state gate (see FA3's own doc for why this
/// stays unconditional rather than visibility-gated: two bool writes
/// plus, at most, a revision-gated roster diff — no DAT access).
/// </summary>
public void Tick()
{
bool inFellowship = _snapshot().IsInFellowship;
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
bool inFellowship = snapshot.IsInFellowship;
_notInFellowshipFrame.Visible = !inFellowship;
_inFellowshipFrame.Visible = inFellowship;
RefreshCheckboxSelections();
RefreshCreateButtonState();
if (!inFellowship)
{
if (_rows.Count != 0)
{
_listBox?.Flush();
_rows.Clear();
}
_selectedFellowGuid = 0u;
_lastRosterRevision = long.MinValue;
_lastOpenState = null;
return;
}
RefreshFellowshipName(snapshot.Name);
if (snapshot.Revision != _lastRosterRevision)
{
_lastRosterRevision = snapshot.Revision;
RefreshRoster(snapshot);
}
RefreshButtonStates(snapshot);
RefreshOpenCaption(snapshot);
}
/// <summary>
/// D4 — the panel-open declaration (<c>0x00A6</c>) that gates ACE's
/// vitals stream (lane B §4.5): without it the roster's health/stamina/
/// mana freeze at whatever they were on join. Called by
/// <see cref="SocialPanelController"/> on every transition of "is the
/// social window shown AND is Fellowship the active tab" — idempotent
/// here too (only sends on an actual flip), and a no-op while
/// disconnected (the command layer returns
/// <see cref="RuntimeCommandStatus.Inactive"/> without sending).
/// </summary>
public void SetPageVisible(bool visible)
{
if (_pageVisible == visible) return;
_pageVisible = visible;
_bindings.SetPanelOpen(visible);
}
private void RefreshCreateButtonState()
{
if (_createButton is null) return;
string name = _nameField?.Text ?? string.Empty;
_createButton.Enabled = !string.IsNullOrWhiteSpace(name);
}
private void RefreshCheckboxSelections()
{
// Both surfaces (this page's checkboxes and the Character tab's
// rows for the SAME four PlayerOption ids) are live views of one
// value — re-seed every tick so a write from the OTHER surface (or
// the retail-exact Ignore<->AutoAccept mutual exclusion,
// RuntimeCharacterState.TrySetOption) is reflected here too
// (lane A §6.5).
if (_ignoreRequestsCheckbox is not null)
_ignoreRequestsCheckbox.Selected =
_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreFellowshipRequests);
if (_autoAcceptCheckbox is not null)
_autoAcceptCheckbox.Selected =
_bindings.CurrentCharacterOption(CharacterOptionId.FellowshipAutoAcceptRequests);
if (_shareXpCheckbox is not null)
_shareXpCheckbox.Selected =
_bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareXP);
if (_shareLootCheckbox is not null)
_shareLootCheckbox.Selected =
_bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareLoot);
}
private void RefreshFellowshipName(string name)
{
if (_fellowshipNameText is null) return;
_fellowshipNameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
}
private void RefreshOpenCaption(RuntimeFellowshipSnapshot snapshot)
{
if (_openButton is null) return;
if (_lastOpenState == snapshot.IsOpen) return;
_lastOpenState = snapshot.IsOpen;
// gmFellowshipUI::UpdateButtons @0x0048E6C0: the button reads as the
// ACTION, not the state — a CLOSED fellowship shows "Open
// Fellowship" (lane A §4.1). Captions are resolved once at Bind
// (see the fields' own doc) — swap between the cached strings only.
string? label = snapshot.IsOpen ? _closeCaption : _openCaption;
if (label is not null)
_openButton.Label = label;
}
private void RefreshButtonStates(RuntimeFellowshipSnapshot snapshot)
{
uint selfGuid = _bindings.LocalPlayerGuid();
bool isLeader = snapshot.LeaderGuid != 0u && snapshot.LeaderGuid == selfGuid;
bool hasSelection = _selectedFellowGuid != 0u;
bool selectedIsSelf = hasSelection && _selectedFellowGuid == selfGuid;
// gmFellowshipUI::UpdateButtons @0x0048E6C0 (lane B §2.8).
if (_quitButton is not null) _quitButton.Enabled = true; // always, while in a fellowship
if (_disbandButton is not null) _disbandButton.Enabled = isLeader;
if (_openButton is not null) _openButton.Enabled = isLeader;
if (_leaderButton is not null) _leaderButton.Enabled = isLeader && hasSelection && !selectedIsSelf;
if (_dismissButton is not null) _dismissButton.Enabled = isLeader && hasSelection && !selectedIsSelf;
if (_recruitButton is not null)
{
uint? targetGuid = _bindings.Selection.SelectedObjectId;
bool targetValid = targetGuid is { } id && id != selfGuid && !_rows.ContainsKey(id);
bool notFull = snapshot.MemberCount < MaxFellowshipSize;
// Retail additionally requires the target to be a player
// (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
// player-vs-non-player classification at this seam, so this
// enable rule is a superset of retail's; the actual Recruit
// send is still refused server-side for a non-player target
// exactly like retail's own silent no-op (lane B §2.3).
_recruitButton.Enabled = targetValid && notFull && (isLeader || snapshot.IsOpen);
}
}
/// <summary>
/// Diffs the live member GUID set against the currently-rendered rows.
/// Unchanged set → update every row's bound widgets in place (no ListBox
/// mutation, scroll untouched). Changed set → full rebuild via
/// <see cref="UiTemplateListBox.FlushPreservingScroll"/> (carry-forward 1).
/// </summary>
private void RefreshRoster(RuntimeFellowshipSnapshot snapshot)
{
if (_listBox is null) return;
var members = new List<RuntimeFellowMemberSnapshot>(_bindings.Members());
bool membershipChanged = members.Count != _rows.Count;
if (!membershipChanged)
{
foreach (RuntimeFellowMemberSnapshot member in members)
{
if (_rows.ContainsKey(member.Guid)) continue;
membershipChanged = true;
break;
}
}
if (membershipChanged)
RebuildRoster(members, snapshot);
else
foreach (RuntimeFellowMemberSnapshot member in members)
UpdateRow(member, snapshot);
if (_selectedFellowGuid != 0u && !_rows.ContainsKey(_selectedFellowGuid))
_selectedFellowGuid = 0u;
}
private void RebuildRoster(List<RuntimeFellowMemberSnapshot> members, RuntimeFellowshipSnapshot snapshot)
{
_listBox!.FlushPreservingScroll();
_rows.Clear();
foreach (RuntimeFellowMemberSnapshot member in members)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null)
{
Console.WriteLine(
"[D.2b] SocialFellowshipPageController: fellow row template did not "
+ $"build for guid 0x{member.Guid:X8}.");
continue;
}
var widgets = new FellowRowWidgets(
UiElement.FindDescendant(row, RowNameTextId) as UiText,
UiElement.FindDescendant(row, RowStatsTextId) as UiText,
UiElement.FindDescendant(row, RowHealthMeterId) as UiMeter,
UiElement.FindDescendant(row, RowStaminaMeterId) as UiMeter,
UiElement.FindDescendant(row, RowManaMeterId) as UiMeter);
_rows[member.Guid] = widgets;
if (widgets.Name is { } nameText)
{
uint guid = member.Guid;
nameText.OnClick = () => SelectFellow(guid);
}
}
foreach (RuntimeFellowMemberSnapshot member in members)
UpdateRow(member, snapshot);
}
private void UpdateRow(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
{
if (!_rows.TryGetValue(member.Guid, out FellowRowWidgets widgets)) return;
if (widgets.Name is { } nameText)
{
string name = member.Name;
Vector4 color = snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
nameText.LinesProvider = () => [new UiText.Line(name, color)];
}
if (widgets.Stats is { } statsText)
{
string text = FormatStatsText(member, snapshot);
statsText.LinesProvider = () => [new UiText.Line(text, MemberNameColor)];
}
SetVitals(widgets.Health, member.CurrentHealth, member.MaxHealth);
SetVitals(widgets.Stamina, member.CurrentStamina, member.MaxStamina);
SetVitals(widgets.Mana, member.CurrentMana, member.MaxMana);
}
/// <summary>
/// D5 — <c>gmFellowshipUI::UpdateFellowStats @0x0048EB20</c> (lane B
/// §2.9): level + XP-share percentage. See the class doc for why the
/// surrounding text is a plain numeric composite rather than retail's
/// resolved sentence. The percentage itself IS retail's exact number:
/// <see cref="EvenSplitPercentTable"/> when even-split, <c>0%</c> when
/// <see cref="RuntimeFellowshipSnapshot.ShareXp"/> is off (retail's own
/// literal <c>pct = 0.0f</c> branch — not a gap), and OMITTED (level
/// only) for the proportional branch, which needs retail's
/// per-level XP-to-next-level table — acdream has not ported one (no
/// acdream ExperienceSystem exists yet); guessing a formula here is
/// exactly what CLAUDE.md's workflow forbids, so this is a scoped,
/// documented gap rather than an invented number.
/// </summary>
private static string FormatStatsText(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
{
if (!snapshot.ShareXp)
return $"{member.Level} 0%";
if (snapshot.EvenXpSplit)
{
float pct = EvenSplitPercent(snapshot.MemberCount);
return $"{member.Level} {(int)MathF.Round(pct * 100f)}%";
}
return member.Level.ToString();
}
private static float EvenSplitPercent(int memberCount) =>
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
private static void SetVitals(UiMeter? meter, uint current, uint max)
{
if (meter is null) return;
meter.Fill = () => max > 0u ? (float)current / max : 0f;
meter.Label = () => $"{current}/{max}";
}
/// <summary>
/// Retail list-selection message (<c>3</c>/<c>0x42</c>,
/// <c>gmFellowshipUI::ListenToElementMessage @0x004901C0</c>, lane B
/// §2.8): reads the clicked row's identity into
/// <c>m_iidSelectedFellow</c> AND calls
/// <c>ACCWeenieObject::SetSelectedObject</c> — selecting a fellow in the
/// panel selects them in the world too. acdream has no generic
/// per-row-element click primitive on an imported template subtree, so
/// the row's own name text (always present per the row inventory) is
/// the click target.
/// </summary>
private void SelectFellow(uint guid)
{
_selectedFellowGuid = guid;
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
}