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:
parent
357d203202
commit
5bdd0528f1
16 changed files with 1941 additions and 114 deletions
|
|
@ -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 >= 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue