empty state, retail amber row selection - Move cursor (user-directed, ALL windows): HoverWindowMove now advertises only where the window frame element itself wins the hit-test - its border pixels; interior points resolve to content children. Matches retail's Dragbar-chrome-only move cursor. Whole-surface dragging still works, it just does not advertise. - Empty-state text (round 2): the DAT stores the LITERAL two-character escape backslash-n (probe-verified - the dump printed the escape, not line breaks), so the round-1 newline split never matched. Escapes are normalized before splitting in DatWidgetFactory authored text. - Selected fellow amber (user: "check retail"): probe-verified - the row name band 0x10000282 AUTHORS the retail selected-row art (DirectState 0x06001450 + Highlight 0x06001451, the amber). Selection flips the band's ActiveState to Highlight; no invented tint. App suite 4,976/3 skips. Confirmation-text + refused-drop-notification research (the round's items 4-5) lands as part 2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
908 lines
44 KiB
C#
908 lines
44 KiB
C#
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 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>
|
|
/// <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. The earlier gold leader tint and blue
|
|
/// selection tint this controller invented for that gap were RETIRED at the
|
|
/// 2026-08-13 gate by user direction: names render white, always
|
|
/// (<see cref="MemberNameColor"/>), and selection feedback is the in-game
|
|
/// selection itself — register row AD-82 updated accordingly.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>World→panel selection (fix-round MUST-FIX 4).</b>
|
|
/// <c>gmFellowshipUI::UpdateFellowSelection @0x0048F0F0</c> is retail's
|
|
/// REVERSE arm of <see cref="SelectFellow"/>'s panel-to-world direction:
|
|
/// selecting a fellow in the 3D world (or by any other selection origin)
|
|
/// selects their panel row too, enabling Dismiss/Assign-Leader. Retail
|
|
/// keys row identity via a generic ListBox primitive
|
|
/// (<c>SetAttribute_InstanceID(row, 0x1000000D, fellowIid)</c> +
|
|
/// <c>UIElement_ListBox::SetSelectedItem</c>) that acdream's
|
|
/// <see cref="UiTemplateListBox"/> does not port (register row AD-82 —
|
|
/// "no Flush, no selection model, no per-row instance-id",
|
|
/// docs/research/2026-08-11-fa-panel-structure.md §6.6). <see cref="Tick"/>
|
|
/// reproduces the OBSERVABLE behavior — button-enable + a visual row
|
|
/// highlight — against this controller's OWN guid-keyed row dictionary
|
|
/// (<see cref="_rows"/>) via <see cref="SyncSelectionFromWorld"/> instead of
|
|
/// a generic ListBox selection model; see AD-82 for what's deferred.
|
|
/// </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) ──
|
|
/// <summary>Gate round 2 (2026-08-13): the row's name BAND — probe-verified
|
|
/// to author the retail selected-row art: DirectState media
|
|
/// <c>0x06001450</c> + a <c>'Highlight'</c> state with the amber
|
|
/// <c>0x06001451</c> (ProbeSocialClickRouting's ROWSTATE dump). Selection
|
|
/// flips its <see cref="UiDatElement.ActiveState"/> — the authored
|
|
/// mechanism, replacing the retired invented tints.</summary>
|
|
private const uint RowNameBandId = 0x10000282u;
|
|
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 >= 9</c> (lane B §7.1).</summary>
|
|
private const int MaxFellowshipSize = 9;
|
|
|
|
/// <summary>2026-08-13 gate (user-directed): fellow names are WHITE,
|
|
/// always — the earlier AD-82 leader-gold and selection-blue tints are
|
|
/// retired (no decompiled anchor ever existed for either); the in-game
|
|
/// selection ring is the selection feedback.</summary>
|
|
private static readonly Vector4 MemberNameColor = Vector4.One;
|
|
|
|
/// <summary>Cached per <see cref="RefreshFellowshipName"/> — only
|
|
/// reassigned when the fellowship name actually changes (fix-round SF-1,
|
|
/// the same zero-allocation-while-idle discipline
|
|
/// <c>SocialAllegiancePageController</c>'s <c>BlankLineProvider</c>/
|
|
/// <c>NoLinesProvider</c> statics established for the sibling page).</summary>
|
|
private string? _lastFellowshipName;
|
|
private Func<IReadOnlyList<UiText.Line>>? _fellowshipNameLinesProvider;
|
|
|
|
/// <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(
|
|
UiDatElement? NameBand,
|
|
UiText? Name,
|
|
UiText? Stats,
|
|
UiMeter? Health,
|
|
UiMeter? Stamina,
|
|
UiMeter? Mana);
|
|
|
|
private readonly UiElement _notInFellowshipFrame;
|
|
private readonly UiElement _inFellowshipFrame;
|
|
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();
|
|
|
|
/// <summary>Fix-round SF-2/SF-3: the TRUE member-guid set as of the last
|
|
/// rebuild attempt, independent of which rows actually finished
|
|
/// building. <see cref="_rows"/> alone under-counts when a row template
|
|
/// permanently fails to build (<see cref="RebuildRoster"/>'s own
|
|
/// <c>continue</c>) — comparing against <see cref="_rows"/>.Count would
|
|
/// make <see cref="RefreshRoster"/>'s membership-changed check true
|
|
/// FOREVER for that guid (every vitals tick re-attempts a full
|
|
/// DAT-locked <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
|
/// rebuild instead of only on a real join/leave/disband), and would let
|
|
/// Recruit's "already a fellow" check
|
|
/// (<see cref="RefreshButtonStates"/>) light up for someone who IS
|
|
/// already a fellow but whose row failed to build. This set is the
|
|
/// single source of truth for both.</summary>
|
|
private readonly HashSet<uint> _memberGuids = 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,
|
|
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;
|
|
_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, Bindings bindings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pageRoot);
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
|
|
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|
|
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
|
|
{
|
|
Console.WriteLine(
|
|
"[D.2b] SocialFellowshipPageController: empty/full frame pair "
|
|
+ "(0x1000026B/0x10000275) not found — fellowship page will not "
|
|
+ "swap its empty state.");
|
|
return null;
|
|
}
|
|
|
|
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();
|
|
bool newOpenState = !snapshot.IsOpen;
|
|
_bindings.SetOpen(newOpenState);
|
|
|
|
// N-0 (fix-round NIT): lane B feature 11 — retail's Open
|
|
// button handler pre-toggles its own _open_fellow BEFORE
|
|
// Event_ChangeFellowOpeness, so the caption flips
|
|
// immediately rather than waiting for the 0x02BE echo.
|
|
// RefreshOpenCaption's own _lastOpenState latch means this
|
|
// optimistic set is a no-op (already matches) once the real
|
|
// snapshot catches up, and is silently corrected on the
|
|
// next Tick if the server ever disagrees.
|
|
_lastOpenState = newOpenState;
|
|
string? label = newOpenState ? _closeCaption : _openCaption;
|
|
if (label is not null)
|
|
_openButton.Label = label;
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
// 2026-08-13 gate fix ("I can't change any options"): these authored
|
|
// checkboxes carry DAT ToggleBehavior, so the button SELF-FLIPS
|
|
// Selected at MouseUp — the old `!checkbox.Selected` here then read
|
|
// the ALREADY-FLIPPED value and wrote the ORIGINAL back, visually
|
|
// snapping every click to where it started (probe-proven:
|
|
// ProbeSocialClickRouting recorded (id, oldValue)). Same CH6a/b
|
|
// mirror discipline as the chat indicators: suppress the blind
|
|
// self-flip, derive the next value from the STORE, and let the
|
|
// per-tick seeding mirror the outcome back onto Selected.
|
|
checkbox.SuppressSelfToggle = true;
|
|
checkbox.OnClick = () =>
|
|
{
|
|
bool next = !_bindings.CurrentCharacterOption(id);
|
|
_bindings.SetCharacterOption(id, next);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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()
|
|
{
|
|
// N-3 (fix-round NIT): Snapshot() and Members() below each take
|
|
// RuntimeFellowshipState._gate separately, so Revision/MemberCount
|
|
// can momentarily disagree with the roster RefreshRoster reads a
|
|
// few lines later. Self-correcting on the next revision bump
|
|
// (single-threaded tick today) — not assumed atomic.
|
|
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();
|
|
}
|
|
_memberGuids.Clear();
|
|
_selectedFellowGuid = 0u;
|
|
_lastRosterRevision = long.MinValue;
|
|
_lastOpenState = null;
|
|
_lastFellowshipName = null;
|
|
_fellowshipNameLinesProvider = null;
|
|
return;
|
|
}
|
|
|
|
RefreshFellowshipName(snapshot.Name);
|
|
|
|
if (snapshot.Revision != _lastRosterRevision)
|
|
{
|
|
_lastRosterRevision = snapshot.Revision;
|
|
RefreshRoster(snapshot);
|
|
}
|
|
|
|
// MUST-FIX 4 (fix round): retail's UpdateFellowSelection runs from
|
|
// Update EVERY frame (not just on a selection-changed notice) —
|
|
// this call site mirrors that placement. See the class doc's
|
|
// world->panel selection note.
|
|
SyncSelectionFromWorld();
|
|
|
|
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;
|
|
// MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): advance the
|
|
// edge-trigger latch ONLY when the 0x00A6 declaration was actually
|
|
// published (Accepted). A generation reset runs BEFORE the
|
|
// reconnected session is in world, so this command is world-gated and
|
|
// returns Inactive there — publishing nothing. The original fix
|
|
// latched unconditionally, leaving `_pageVisible = true` while the
|
|
// fresh server was never told, so the intended post-world
|
|
// re-declaration became a no-op (already latched) and fellow vitals
|
|
// stayed frozen. Leaving the latch untouched on a dropped publish
|
|
// lets RedeclareAfterWorldEntry retry exactly once, in world.
|
|
if (_bindings.SetPanelOpen(visible).Status == RuntimeCommandStatus.Accepted)
|
|
_pageVisible = visible;
|
|
}
|
|
|
|
/// <summary>
|
|
/// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT
|
|
/// sending anything, so the next <see cref="SetPageVisible"/> call
|
|
/// re-declares <c>0x00A6</c> even if the caller's own visibility
|
|
/// hasn't changed from ITS perspective. Called by
|
|
/// <see cref="SocialPanelController.ResetSessionDeclaration"/> on every
|
|
/// generation reset — the page itself is process-lifetime and survives
|
|
/// a reconnect, but the fresh session's server has no memory of the
|
|
/// previous session's <c>0x00A6</c> declaration.
|
|
/// </summary>
|
|
public void ResetPageVisibleLatch() => _pageVisible = false;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Fix-round SF-1: reassigns <see cref="UiText.LinesProvider"/>
|
|
/// (a display-class + delegate allocation) only when the name actually
|
|
/// changed, instead of on every <see cref="Tick"/> — this method used to
|
|
/// allocate once per frame even while the panel is hidden, exactly the
|
|
/// bug <c>35c40a9b</c> fixed on the sibling
|
|
/// <c>SocialAllegiancePageController</c>.</summary>
|
|
private void RefreshFellowshipName(string name)
|
|
{
|
|
if (_fellowshipNameText is null) return;
|
|
if (_fellowshipNameLinesProvider is not null && _lastFellowshipName == name) return;
|
|
|
|
_lastFellowshipName = name;
|
|
_fellowshipNameLinesProvider = () => [new UiText.Line(name, MemberNameColor)];
|
|
_fellowshipNameText.LinesProvider = _fellowshipNameLinesProvider;
|
|
}
|
|
|
|
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;
|
|
// Fix-round SF-3: reads TRUE membership (_memberGuids), not
|
|
// which rows happen to have finished building (_rows) — a row
|
|
// that failed to build for a permanently-unresolvable template
|
|
// must not let Recruit re-invite someone who IS already a
|
|
// fellow.
|
|
bool targetValid = targetGuid is { } id && id != selfGuid && !_memberGuids.Contains(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 (<see cref="_memberGuids"/> — fix-round
|
|
/// SF-2, NOT <see cref="_rows"/>.Count, see that field's own doc) against
|
|
/// the current roster. 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 != _memberGuids.Count;
|
|
if (!membershipChanged)
|
|
{
|
|
foreach (RuntimeFellowMemberSnapshot member in members)
|
|
{
|
|
if (_memberGuids.Contains(member.Guid)) continue;
|
|
membershipChanged = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (membershipChanged)
|
|
RebuildRoster(members, snapshot);
|
|
else
|
|
foreach (RuntimeFellowMemberSnapshot member in members)
|
|
UpdateRow(member, snapshot);
|
|
|
|
if (_selectedFellowGuid != 0u && !_memberGuids.Contains(_selectedFellowGuid))
|
|
_selectedFellowGuid = 0u;
|
|
}
|
|
|
|
private void RebuildRoster(List<RuntimeFellowMemberSnapshot> members, RuntimeFellowshipSnapshot snapshot)
|
|
{
|
|
_listBox!.FlushPreservingScroll();
|
|
_rows.Clear();
|
|
|
|
// Fix-round SF-2: record the FULL attempted member set up front,
|
|
// regardless of which rows below actually finish building — a
|
|
// permanently-unbuildable template is attempted once per real
|
|
// membership change, not once per vitals tick forever.
|
|
_memberGuids.Clear();
|
|
foreach (RuntimeFellowMemberSnapshot member in members)
|
|
_memberGuids.Add(member.Guid);
|
|
|
|
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, RowNameBandId) as UiDatElement,
|
|
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);
|
|
}
|
|
// 2026-08-13 gate fix ("I only get the move window cursor"):
|
|
// give the STATS text the same click target so most of the row's
|
|
// width selects the fellow (UiText.OnClick now clears the
|
|
// display-text ClickThrough default — see UiText.OnClick's doc).
|
|
if (widgets.Stats is { } statsText)
|
|
{
|
|
uint guid = member.Guid;
|
|
statsText.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;
|
|
// 2026-08-13 gate (user-directed): fellow names render WHITE,
|
|
// always — the AD-82 invented leader/selection tints are retired;
|
|
// selection feedback is the in-game selection itself
|
|
// (SelectFellow drives the world selection ring).
|
|
nameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
|
|
}
|
|
|
|
// Gate round 2: the AUTHORED selected-row cue — the name band's
|
|
// 'Highlight' state is retail's amber (see RowNameBandId's doc).
|
|
if (widgets.NameBand is { } nameBand)
|
|
nameBand.ActiveState = _selectedFellowGuid == member.Guid ? "Highlight" : "";
|
|
|
|
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.
|
|
///
|
|
/// <para>
|
|
/// <b>MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round.</b>
|
|
/// <c>gmFellowshipUI::UpdateFellowStats @0x0048ECC9</c> forms
|
|
/// <c>pct * 100.0f</c> on the x87 stack (exact — both operands are
|
|
/// floats) then calls <c>_ftol2 @0x005DE394</c> (MSVC's helper that
|
|
/// turns the FPU's round-to-nearest <c>fistp</c> into C's
|
|
/// truncate-toward-zero), never <c>MathF.Round</c>. This matters ONLY
|
|
/// at 6 and 8 fellows: the stored constants are
|
|
/// <c>0.44999998807907104</c> and <c>0.3499999940395355</c> (byte-read
|
|
/// from the PDB-paired binary), so retail's own products are
|
|
/// <c>44.999998…</c> / <c>34.999999…</c> and truncate to <b>44</b> /
|
|
/// <b>34</b> — a naive <c>(int)MathF.Round(pct * 100f)</c> (or even
|
|
/// <c>(int)(pct * 100f)</c>, since <c>0.45f * 100f</c> already rounds UP
|
|
/// to exactly <c>45.0f</c> in SINGLE precision) yields the wrong 45/35.
|
|
/// Forming the product in <c>double</c> before truncating reproduces
|
|
/// retail's wider-than-single-precision x87 product exactly.
|
|
/// </para>
|
|
/// </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)((double)pct * 100.0)}%";
|
|
}
|
|
return member.Level.ToString();
|
|
}
|
|
|
|
private static float EvenSplitPercent(int memberCount) =>
|
|
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
|
|
|
|
/// <summary>
|
|
/// N-1 (fix-round NIT): retail's <c>UpdateFellowVitals @0x0048ED60</c>
|
|
/// sets meter attribute <c>0x69</c> to <c>cur/max</c> AND writes the
|
|
/// cur/max ints into the meter's authored CHILD text elements
|
|
/// (<c>0x10000286</c> under health, <c>0x10000288</c> under stamina) —
|
|
/// acdream instead uses <see cref="UiMeter.Fill"/>/
|
|
/// <see cref="UiMeter.Label"/> because <see cref="UiMeter.ConsumesDatChildren"/>
|
|
/// is true (a pre-existing widget decision, not this slice's). Noted
|
|
/// here so a future reader does not go hunting for the unbound
|
|
/// <c>0x10000286</c>/<c>0x10000288</c>/<c>0x1000028A</c> children.
|
|
/// N-2: the <c>max > 0</c> guard below is an acdream-side engineering
|
|
/// call (retail divides unconditionally) — the right one, since a
|
|
/// pre-population zero-max member should not render a divide-by-zero
|
|
/// fill, but not itself a "verbatim port" claim.
|
|
/// </summary>
|
|
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 (register row AD-82 — retail selects on the whole
|
|
/// row, not just its name text).
|
|
/// </summary>
|
|
private void SelectFellow(uint guid)
|
|
{
|
|
SetSelectedFellow(guid);
|
|
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
|
|
}
|
|
|
|
/// <summary>
|
|
/// MUST-FIX 4 (fix round) — the world→panel arm of retail's
|
|
/// two-directional selection coupling (see the class doc). Retail's
|
|
/// <c>UpdateFellowSelection</c> loop has two arms: a world selection
|
|
/// that matches a fellow row becomes the panel selection (this
|
|
/// method's job — the "found" arm); otherwise the CURRENT panel
|
|
/// selection is kept as a fallback as long as that fellow is still on
|
|
/// the roster (retail's "remember row as the fallback" arm — no code
|
|
/// needed here, since this method simply does nothing when the world
|
|
/// selection doesn't match a member, leaving <see cref="_selectedFellowGuid"/>
|
|
/// untouched), and <see cref="RefreshRoster"/> already clears the
|
|
/// selection when its fellow leaves the roster (retail's
|
|
/// fallback-is-null case).
|
|
/// </summary>
|
|
private void SyncSelectionFromWorld()
|
|
{
|
|
if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id))
|
|
SetSelectedFellow(id);
|
|
}
|
|
|
|
/// <summary>Sets the panel's own selection (idempotent) and immediately
|
|
/// refreshes every row's visual selection cue — selection changes are
|
|
/// rare (a click, or a world-selection change), so re-running
|
|
/// <see cref="UpdateRow"/> for the bounded (≤9) roster on a change
|
|
/// is cheap and keeps the highlight in sync without waiting for the
|
|
/// next vitals-driven revision bump.</summary>
|
|
private void SetSelectedFellow(uint guid)
|
|
{
|
|
if (_selectedFellowGuid == guid) return;
|
|
_selectedFellowGuid = guid;
|
|
|
|
if (_rows.Count == 0) return;
|
|
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
|
|
foreach (RuntimeFellowMemberSnapshot member in _bindings.Members())
|
|
UpdateRow(member, snapshot);
|
|
}
|
|
}
|