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
|
|
@ -876,11 +876,28 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
// directly (the panel's read-only rows need full-collection
|
||||
// enumeration, not the bot-facing IRuntimeSocialView's
|
||||
// per-id lookup).
|
||||
// Campaign FA slice FA4: the seven Fellowship write commands
|
||||
// route through `late.GameRuntime` (DeferredGameRuntimeStateCommands)
|
||||
// — the same late-bound, generation-race-safe seam
|
||||
// AddShortcut/Advance/etc already use — NOT a raw
|
||||
// WorldSession.SendXxx call (see SocialRuntimeBindings' own
|
||||
// FA4 doc addendum for why Quit specifically must not
|
||||
// bypass the command layer).
|
||||
Social: new SocialRuntimeBindings(
|
||||
() => d.Runtime.Fellowship.Snapshot,
|
||||
() => d.Runtime.Allegiance.Snapshot,
|
||||
d.Communication.Friends,
|
||||
d.Communication.Squelch),
|
||||
d.Communication.Squelch,
|
||||
() => d.Runtime.Fellowship.GetMembers(),
|
||||
(name, shareXp) => late.GameRuntime.FellowshipCreate(name, shareXp),
|
||||
guid => late.GameRuntime.FellowshipRecruit(guid),
|
||||
guid => late.GameRuntime.FellowshipDismiss(guid),
|
||||
disband => late.GameRuntime.FellowshipQuit(disband),
|
||||
guid => late.GameRuntime.FellowshipAssignLeader(guid),
|
||||
isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
|
||||
panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
|
||||
d.Actions.Selection,
|
||||
() => d.PlayerIdentity.ServerGuid),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,46 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
generation,
|
||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
||||
|
||||
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||
// Same "capture view+commands under one generation" shape as every
|
||||
// method above — a displaced session (reconnect mid-click) can never
|
||||
// receive a stale-generation fellowship action.
|
||||
|
||||
public RuntimeCommandResult FellowshipCreate(string fellowshipName, bool shareXp) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.Create(
|
||||
generation, fellowshipName, shareXp));
|
||||
|
||||
public RuntimeCommandResult FellowshipRecruit(uint targetGuid) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.Recruit(
|
||||
generation, targetGuid));
|
||||
|
||||
public RuntimeCommandResult FellowshipDismiss(uint targetGuid) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.Dismiss(
|
||||
generation, targetGuid));
|
||||
|
||||
public RuntimeCommandResult FellowshipQuit(bool disband) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.Quit(
|
||||
generation, disband));
|
||||
|
||||
public RuntimeCommandResult FellowshipAssignLeader(uint newLeaderGuid) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.AssignLeader(
|
||||
generation, newLeaderGuid));
|
||||
|
||||
public RuntimeCommandResult FellowshipSetOpen(bool isOpen) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.SetOpen(
|
||||
generation, isOpen));
|
||||
|
||||
/// <summary><c>0x00A6</c> — D4's panel-open/close declaration (the
|
||||
/// vitals-stream gate, lane B §4.5). Idempotent at the CALLER
|
||||
/// (<see cref="AcDream.App.UI.Layout.SocialFellowshipPageController"/>
|
||||
/// only invokes this on an actual visibility transition); this method
|
||||
/// itself just forwards, exactly like every sibling command — a call
|
||||
/// while disconnected returns <see cref="RuntimeCommandStatus.Inactive"/>
|
||||
/// from <see cref="Invoke"/> without sending anything.</summary>
|
||||
public RuntimeCommandResult FellowshipSetPanelOpen(bool panelOpen) =>
|
||||
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
|
||||
generation, panelOpen));
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
lock (_gate)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
|
|||
/// steps 13-18, written after the code landed):
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>NOT dimmed (15 rows, real acdream-side
|
||||
/// <item><description><b>NOT dimmed (19 rows, real acdream-side
|
||||
/// consumer):</b> Group B rows actually bound —
|
||||
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
||||
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
||||
|
|
@ -122,10 +122,17 @@ public static class CharacterOptionsPageController
|
|||
/// <c>DisableDistanceFog</c>/<c>DisplayTimeStamps</c>/<c>ToggleRun</c>
|
||||
/// (Group B, polled at <c>GameWindow.cs:655-680</c>),
|
||||
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
|
||||
/// <c>InteractionRetainedUiComposition.cs:326</c>), and all six
|
||||
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six
|
||||
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
|
||||
/// gates every Turbine room join on the matching bit).</description></item>
|
||||
/// <item><description><b>Dimmed (35 rows, store-only):</b> every
|
||||
/// gates every Turbine room join on the matching bit), and — landed at
|
||||
/// Campaign FA slice FA4, D7 — <c>IgnoreFellowshipRequests</c>/
|
||||
/// <c>FellowshipAutoAcceptRequests</c> (consumed by
|
||||
/// <c>RetailUiRuntime.TryAutoRespondToFellowshipInvite</c>'s auto-
|
||||
/// decline/auto-accept, D6) and <c>FellowshipShareXP</c>/
|
||||
/// <c>FellowshipShareLoot</c> (consumed by the fellowship page's own
|
||||
/// Create flow / D5 display / second checkbox surface,
|
||||
/// <c>SocialFellowshipPageController</c>).</description></item>
|
||||
/// <item><description><b>Dimmed (31 rows, store-only):</b> every
|
||||
/// remaining Group A row (wire+store only — ACE, not acdream, is the
|
||||
/// consumer) and every remaining Group B row the OP4 gate script's own
|
||||
/// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>,
|
||||
|
|
@ -134,8 +141,10 @@ public static class CharacterOptionsPageController
|
|||
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
|
||||
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
||||
/// deferral (<c>SalvageMultiple</c>,
|
||||
/// <c>DisableHouseRestrictionEffects</c>, <c>FellowshipShareXP</c>,
|
||||
/// <c>HearPkDeathMessages</c>).</description></item>
|
||||
/// <c>DisableHouseRestrictionEffects</c>,
|
||||
/// <c>HearPkDeathMessages</c>) — <c>FellowshipShareXP</c> left Group D
|
||||
/// at FA4 (above), it stays a bullet-list resident here only for the
|
||||
/// historical count.</description></item>
|
||||
/// <item><description><b>Flagged ambiguity, resolved by code evidence
|
||||
/// (see final report, not re-litigated here):</b> the research doc's
|
||||
/// §7.1 lists <c>AcceptLootPermits</c> in BOTH Group A's bullet list AND
|
||||
|
|
@ -145,13 +154,14 @@ public static class CharacterOptionsPageController
|
|||
/// not a behavioral reader — ACE alone decides whether to honor corpse
|
||||
/// looting (<c>Player_Death.cs:755</c>). Classified Group A / dimmed.
|
||||
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
|
||||
/// have a genuine coded mutual-exclusion side effect
|
||||
/// had a genuine coded mutual-exclusion side effect
|
||||
/// (<c>RuntimeCharacterState.TrySetOption</c>, MF-2) that the research
|
||||
/// doc's Group A classification does not capture (the doc predates
|
||||
/// OP1's review-fix round) — kept dimmed per the doc's explicit Group A
|
||||
/// listing and this task's instruction to treat the doc as the sole
|
||||
/// authority rather than infer from code comments; a future un-dim is a
|
||||
/// one-line judgment call, not a re-investigation.</description></item>
|
||||
/// doc's Group A classification did not capture, kept dimmed at OP1
|
||||
/// pending "a future un-dim is a one-line judgment call, not a
|
||||
/// re-investigation" — that future consumer landed at Campaign FA slice
|
||||
/// FA4 (see D7's addendum to register row AD-78), so both rows moved to
|
||||
/// the NOT-dimmed bullet above, together with the two
|
||||
/// <c>FellowshipShare*</c> rows FA4 also gave real consumers.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private const bool Live = false;
|
||||
|
|
@ -192,12 +202,12 @@ public static class CharacterOptionsPageController
|
|||
}),
|
||||
new("ID_CharacterOption_Grouping_Section", new RowSpec[]
|
||||
{
|
||||
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A
|
||||
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note)
|
||||
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A
|
||||
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D
|
||||
new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // Group A
|
||||
new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // Group A (see ambiguity note)
|
||||
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
|
||||
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-decline (RetailUiRuntime.TryAutoRespondToFellowshipInvite)
|
||||
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
|
||||
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", Live), // D7 (Campaign FA slice FA4): consumed by the fellowship Create flow (its value IS the sent shareXP bit) and the panel's D5 XP-share display
|
||||
new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", Live), // D7 (Campaign FA slice FA4): now also a SECOND live checkbox surface on the fellowship page itself (SocialFellowshipPageController)
|
||||
new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-accept (suppresses the confirmation dialog)
|
||||
}),
|
||||
new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[]
|
||||
{
|
||||
|
|
|
|||
74
src/AcDream.App/UI/Layout/RowTemplateResolver.cs
Normal file
74
src/AcDream.App/UI/Layout/RowTemplateResolver.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Caching row-template resolver for <see cref="UiTemplateListBox"/>
|
||||
/// consumers whose row COUNT can change across a session (a live roster,
|
||||
/// not a fixed authored set) — Friends, Squelch, and (Campaign FA slice
|
||||
/// FA4) Fellowship all resolve their <c>0x64</c> template entries through
|
||||
/// one instance of this class.
|
||||
///
|
||||
/// <para>
|
||||
/// Originally a private local function inside
|
||||
/// <see cref="RetailUiRuntime.MountSocialPanel"/> (Campaign FA slice FA3
|
||||
/// fix-round blast SF-2): the ORIGINAL shape re-ran
|
||||
/// <c>LayoutImporter.ImportInfos</c> — a full DAT tree walk — under the
|
||||
/// shared DAT lock on EVERY row, every revision, even while the panel was
|
||||
/// closed. The fix caches each template id pair's resolved
|
||||
/// <see cref="ElementInfo"/> tree the FIRST time it is imported and never
|
||||
/// re-imports for that pair again; <see cref="Resolve"/> still calls
|
||||
/// <paramref name="build"/> (constructor parameter) on every invocation
|
||||
/// because each row needs its OWN <see cref="UiElement"/> instance — only
|
||||
/// the expensive per-row DAT tree WALK is memoized, not the built widget.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Extracted to its own class in FA4 (carry-forward 2, the FA3 re-review's
|
||||
/// non-blocking finding: "add a production-resolver test for the new
|
||||
/// template cache") so this caching behavior is unit-testable against fake
|
||||
/// <paramref name="importInfos"/>/<paramref name="build"/> delegates — no
|
||||
/// live DAT access needed to prove the cache actually short-circuits a
|
||||
/// repeat import. Production callers still take the shared DAT lock
|
||||
/// AROUND <see cref="Resolve"/> at the call site (see
|
||||
/// <c>RetailUiRuntime.MountSocialPanel</c>) — this class has no lock of its
|
||||
/// own, matching the "controller has no DAT dependency of its own" shape
|
||||
/// every other hermetically-testable page controller in this codebase
|
||||
/// already follows.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RowTemplateResolver
|
||||
{
|
||||
private readonly Dictionary<(uint LayoutId, uint ElementId), ElementInfo?> _cache = new();
|
||||
private readonly Func<uint, uint, ElementInfo?> _importInfos;
|
||||
private readonly Func<ElementInfo, UiElement?> _build;
|
||||
|
||||
/// <summary>Number of times <paramref name="importInfos"/> (the constructor
|
||||
/// parameter) actually ran — i.e. cache MISSES. Exposed for the
|
||||
/// production-resolver conformance test; not used by any production
|
||||
/// code path.</summary>
|
||||
public int ImportCount { get; private set; }
|
||||
|
||||
public RowTemplateResolver(
|
||||
Func<uint, uint, ElementInfo?> importInfos,
|
||||
Func<ElementInfo, UiElement?> build)
|
||||
{
|
||||
_importInfos = importInfos ?? throw new ArgumentNullException(nameof(importInfos));
|
||||
_build = build ?? throw new ArgumentNullException(nameof(build));
|
||||
}
|
||||
|
||||
/// <summary>Resolves one row template. Null if the (layoutId, elementId)
|
||||
/// pair does not import (cached as a miss — a permanently-unresolvable
|
||||
/// template is not retried on every call; see the class doc's cache
|
||||
/// contract). <see cref="UiTemplateListBox.TemplateResolver"/>'s exact
|
||||
/// signature.</summary>
|
||||
public UiElement? Resolve(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
var key = (templateLayoutId, templateElementId);
|
||||
if (!_cache.TryGetValue(key, out ElementInfo? info))
|
||||
{
|
||||
info = _importInfos(templateLayoutId, templateElementId);
|
||||
_cache[key] = info;
|
||||
ImportCount++;
|
||||
}
|
||||
return info is null ? null : _build(info);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,15 +36,20 @@ namespace AcDream.App.UI.Layout;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x10000517</c>
|
||||
/// authors a direct sibling scrollbar, <c>0x10000518</c> — wired to
|
||||
/// <see cref="UiTemplateListBox.Scroll"/> the SAME way every other
|
||||
/// <c>UiTemplateListBox</c> consumer wires its own scrollbar
|
||||
/// (<c>CharacterOptionsPageController</c>/<c>ChatOptionsPageController</c>/
|
||||
/// <c>ConfigOptionsPageController</c>/<c>KeyboardConfigController</c>).
|
||||
/// Without it the list has NO wheel fallback (<see cref="UiScrollablePanel"/>
|
||||
/// has no wheel handler) and is completely unreachable past the box's
|
||||
/// visible extent.
|
||||
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
||||
/// ListBox <c>0x10000517</c> authors a direct sibling scrollbar,
|
||||
/// <c>0x10000518</c> — wired to <see cref="UiTemplateListBox.Scroll"/> the
|
||||
/// SAME way every other <c>UiTemplateListBox</c> consumer wires its own
|
||||
/// scrollbar. Resolved via the built <see cref="UiTemplateListBox.ScrollbarElementId"/>
|
||||
/// (dat property <c>0x72</c>, populated by <see cref="ElementReader"/>)
|
||||
/// rather than the hardcoded literal a prior revision of this file used —
|
||||
/// same "prefer the DAT field over a duplicated constant" fix
|
||||
/// <c>ConfigOptionsPageController.Bind</c> already applied (OP6 review N2,
|
||||
/// <c>feedback_prefer_dat_field_over_geometry</c>), scoped to this page's
|
||||
/// own subtree so a flat lookup can't grab a same-id sibling on another
|
||||
/// page. Without it the list has NO wheel fallback
|
||||
/// (<see cref="UiScrollablePanel"/> has no wheel handler) and is completely
|
||||
/// unreachable past the box's visible extent.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController
|
|||
{
|
||||
private const uint ListBoxId = 0x10000517u;
|
||||
|
||||
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
|
||||
private const uint ScrollbarElementId = 0x10000518u;
|
||||
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly FriendsState _friends;
|
||||
private long _lastRevision = long.MinValue;
|
||||
|
|
@ -95,11 +97,15 @@ public sealed class SocialFriendsPageController
|
|||
}
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
|
||||
uint scrollbarElementId = listBox.ScrollbarElementId;
|
||||
UiElement? scrollbarElement = scrollbarElementId == 0
|
||||
? null
|
||||
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
|
||||
if (scrollbarElement is UiScrollbar scrollbar)
|
||||
scrollbar.Model = listBox.Scroll;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
||||
$"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
|
||||
+ "not found — the Friends list will not scroll.");
|
||||
|
||||
var controller = new SocialFriendsPageController(listBox, friends);
|
||||
|
|
|
|||
|
|
@ -86,10 +86,15 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
private const uint CloseButtonId = 0x10000290u;
|
||||
|
||||
/// <summary>Callback delegates + live-state accessors this controller
|
||||
/// wires the four pages and the close button to.</summary>
|
||||
/// wires the four pages and the close button to. Campaign FA slice FA4:
|
||||
/// <see cref="Fellowship"/> replaces the old bare
|
||||
/// <c>FellowshipSnapshot</c> field with the page's full read/write seam
|
||||
/// (roster enumeration, the seven commands, selection, character
|
||||
/// options, string resolution — see
|
||||
/// <see cref="SocialFellowshipPageController.Bindings"/>'s own doc).</summary>
|
||||
public sealed record Callbacks(
|
||||
Action Toggle,
|
||||
Func<RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
||||
SocialFellowshipPageController.Bindings Fellowship,
|
||||
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
||||
FriendsState Friends,
|
||||
SquelchState Squelch,
|
||||
|
|
@ -122,6 +127,17 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
_allegiance = allegiance;
|
||||
_friends = friends;
|
||||
_squelch = squelch;
|
||||
|
||||
// Campaign FA slice FA4, D4: the Fellowship page's 0x00A6
|
||||
// panel-open declaration needs both "is the social WINDOW shown"
|
||||
// (OnShown/OnHidden, below) AND "is Fellowship the ACTIVE tab"
|
||||
// (this event) — retail's gmFellowshipUI::OnVisibilityChanged fires
|
||||
// on the WIDGET's own visibility, and in this campaign's one-window/
|
||||
// four-page mount that's the conjunction of both. Subscribed here
|
||||
// (not in Bind) so it observes every tab switch, including the
|
||||
// very first one ActivateTabBehavior's default-entry activation
|
||||
// fires.
|
||||
_tabPanel.ActivePageChanged += (_, _) => UpdateFellowshipPageVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -156,7 +172,7 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
|
||||
SocialFellowshipPageController? fellowship = fellowshipPage is null
|
||||
? null
|
||||
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot);
|
||||
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
|
||||
SocialAllegiancePageController? allegiance = allegiancePage is null
|
||||
? null
|
||||
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
|
||||
|
|
@ -229,12 +245,27 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
/// window manager on every hidden-to-shown transition (fix-round blast
|
||||
/// SF-2). Does not force an immediate Friends/Squelch rebuild — the
|
||||
/// next <see cref="Tick"/> naturally picks up any revision bump that
|
||||
/// accumulated while hidden.</summary>
|
||||
public void OnShown() => _visible = true;
|
||||
/// accumulated while hidden. Also drives D4 (see the constructor's own
|
||||
/// doc on <see cref="UiTabPanel.ActivePageChanged"/>).</summary>
|
||||
public void OnShown()
|
||||
{
|
||||
_visible = true;
|
||||
UpdateFellowshipPageVisibility();
|
||||
}
|
||||
|
||||
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
|
||||
/// shown-to-hidden transition (fix-round blast SF-2).</summary>
|
||||
public void OnHidden() => _visible = false;
|
||||
/// shown-to-hidden transition (fix-round blast SF-2). Also drives D4.</summary>
|
||||
public void OnHidden()
|
||||
{
|
||||
_visible = false;
|
||||
UpdateFellowshipPageVisibility();
|
||||
}
|
||||
|
||||
/// <summary>D4: the Fellowship page is "visible" (and therefore
|
||||
/// declares its panel-open state to Runtime) exactly when the social
|
||||
/// WINDOW is shown AND Fellowship is the active tab.</summary>
|
||||
private void UpdateFellowshipPageVisibility() =>
|
||||
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
|
||||
|
|
@ -247,11 +278,16 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
/// <b>Fix-round blast SF-2.</b> The Friends/Squelch rebuild
|
||||
/// (<see cref="SocialFriendsPageController.Tick"/>/
|
||||
/// <see cref="SocialSquelchPageController.Tick"/>) takes the shared DAT
|
||||
/// lock per row template resolve — it only runs while <see cref="_visible"/>
|
||||
/// is true. Fellowship/Allegiance stay unconditional: their own
|
||||
/// <c>Tick()</c> methods are two bool/delegate writes with no DAT
|
||||
/// access, cheap enough to keep polling every frame the way every other
|
||||
/// retained panel's empty-state gate already does.
|
||||
/// lock per row template resolve on EVERY revision change — it only
|
||||
/// runs while <see cref="_visible"/> is true. Fellowship/Allegiance stay
|
||||
/// unconditional: Allegiance's own <c>Tick()</c> is two bool/delegate
|
||||
/// writes with no DAT access; Fellowship's (FA4) is revision-GATED
|
||||
/// internally and, within that gate, further diffs the member set
|
||||
/// before ever touching the ListBox — a DAT-locked row rebuild only
|
||||
/// runs on an actual join/leave/disband, an inherently rare event
|
||||
/// (unlike Friends/Squelch's "any revision → full rebuild" shape), so
|
||||
/// polling unconditionally stays cheap in the common case (a snapshot
|
||||
/// read + a `long` comparison) even while the panel is hidden.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
|
|
|
|||
|
|
@ -31,12 +31,14 @@ namespace AcDream.App.UI.Layout;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x1000053E</c>
|
||||
/// authors a direct sibling scrollbar, <c>0x10000543</c> — wired to
|
||||
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
||||
/// ListBox <c>0x1000053E</c> authors a direct sibling scrollbar,
|
||||
/// <c>0x10000543</c> — resolved via the built
|
||||
/// <see cref="UiTemplateListBox.ScrollbarElementId"/> and wired to
|
||||
/// <see cref="UiTemplateListBox.Scroll"/> the same way
|
||||
/// <see cref="SocialFriendsPageController"/> wires its own (see that
|
||||
/// class's doc for the full rationale, including why there is no wheel
|
||||
/// fallback).
|
||||
/// fallback and why the authored field replaces a hardcoded literal).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -52,9 +54,6 @@ public sealed class SocialSquelchPageController
|
|||
{
|
||||
private const uint ListBoxId = 0x1000053Eu;
|
||||
|
||||
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
|
||||
private const uint ScrollbarElementId = 0x10000543u;
|
||||
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly SquelchState _squelch;
|
||||
private long _lastRevision = long.MinValue;
|
||||
|
|
@ -83,11 +82,15 @@ public sealed class SocialSquelchPageController
|
|||
}
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
|
||||
uint scrollbarElementId = listBox.ScrollbarElementId;
|
||||
UiElement? scrollbarElement = scrollbarElementId == 0
|
||||
? null
|
||||
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
|
||||
if (scrollbarElement is UiScrollbar scrollbar)
|
||||
scrollbar.Model = listBox.Scroll;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
||||
$"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
|
||||
+ "not found — the Squelch list will not scroll.");
|
||||
|
||||
var controller = new SocialSquelchPageController(listBox, squelch);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Input;
|
||||
|
|
@ -225,12 +226,42 @@ public sealed record OptionsRuntimeBindings(
|
|||
/// the panel's read-only rows need the actual entries, same access shape
|
||||
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
|
||||
/// already exposes for every other Communication-owned consumer).
|
||||
///
|
||||
/// <para>
|
||||
/// Campaign FA slice FA4 adds the Fellowship page's write surface:
|
||||
/// <see cref="FellowshipMembers"/> (the roster <see cref="IRuntimeFellowshipView"/>
|
||||
/// alone cannot enumerate — see that interface's own FA4 addendum) and the
|
||||
/// seven generation-gated commands, all routed through
|
||||
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
|
||||
/// (the SAME late-bound, race-safe seam <c>AddShortcut</c>/<c>Advance</c>
|
||||
/// already use) rather than a raw <c>WorldSession.SendXxx</c> call —
|
||||
/// <c>FellowshipQuit</c> in particular MUST go through
|
||||
/// <c>IRuntimeFellowshipCommands.Quit</c> because the retail leader
|
||||
/// hand-off rule (<c>0x0290</c> before <c>0x00A3</c>) lives in
|
||||
/// <c>RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit</c> +
|
||||
/// <c>DirectGameRuntimeCommandAdapter.Quit</c>/
|
||||
/// <c>CurrentGameRuntimeCommandAdapter.Quit</c>, not in
|
||||
/// <c>WorldSession</c> itself — bypassing the command layer would silently
|
||||
/// drop that behavior. <see cref="Selection"/> is the Recruit target source
|
||||
/// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
|
||||
/// currently-selected WORLD object, not a panel-local selection).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SocialRuntimeBindings(
|
||||
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
||||
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
||||
AcDream.Core.Social.FriendsState Friends,
|
||||
AcDream.Core.Social.SquelchState Squelch);
|
||||
AcDream.Core.Social.SquelchState Squelch,
|
||||
Func<IEnumerable<AcDream.Runtime.RuntimeFellowMemberSnapshot>> FellowshipMembers,
|
||||
Func<string, bool, RuntimeCommandResult> FellowshipCreate,
|
||||
Func<uint, RuntimeCommandResult> FellowshipRecruit,
|
||||
Func<uint, RuntimeCommandResult> FellowshipDismiss,
|
||||
Func<bool, RuntimeCommandResult> FellowshipQuit,
|
||||
Func<uint, RuntimeCommandResult> FellowshipAssignLeader,
|
||||
Func<bool, RuntimeCommandResult> FellowshipSetOpen,
|
||||
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
|
||||
SelectionState Selection,
|
||||
Func<uint> LocalPlayerGuid);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -631,8 +662,35 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA4, D6: a fellowship invite (<c>ConfirmationType.
|
||||
/// Fellowship</c>, 4) is intercepted BEFORE it ever reaches the generic
|
||||
/// <see cref="GameplayConfirmationController"/> dialog — retail's
|
||||
/// <c>Fellowship.cs:121</c>-equivalent client-side mirror:
|
||||
/// <c>IgnoreFellowshipRequests</c> auto-declines, <c>FellowshipAutoAcceptRequests</c>
|
||||
/// auto-accepts, and the two are mutually exclusive
|
||||
/// (<c>RuntimeCharacterState.TrySetOption</c>, already ported) so at
|
||||
/// most one fires. Neither bit set → falls through to the generic
|
||||
/// controller exactly as it already did before this slice (it already
|
||||
/// matches the constants — see D6/lane A §5.3).
|
||||
/// </summary>
|
||||
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
|
||||
=> _gameplayConfirmationController?.HandleRequest(request) == true;
|
||||
{
|
||||
if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship
|
||||
&& TryAutoRespondToFellowshipInvite(request))
|
||||
return true;
|
||||
return _gameplayConfirmationController?.HandleRequest(request) == true;
|
||||
}
|
||||
|
||||
private bool TryAutoRespondToFellowshipInvite(GameEvents.CharacterConfirmationRequest request)
|
||||
{
|
||||
bool ignore = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.IgnoreFellowshipRequests);
|
||||
bool autoAccept = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.FellowshipAutoAcceptRequests);
|
||||
if (!ignore && !autoAccept) return false;
|
||||
|
||||
_bindings.Confirmations.SendResponse(request.Type, request.ContextId, autoAccept);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
|
||||
=> _gameplayConfirmationController?.HandleDone(done) == true;
|
||||
|
|
@ -2641,20 +2699,19 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// resolvers read the shared, non-thread-safe DatCollection) because
|
||||
// each row needs its OWN UiElement instance — but the expensive
|
||||
// per-row DAT tree walk is gone after the first resolve.
|
||||
var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>();
|
||||
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
// FA4 carry-forward 2: the caching logic itself now lives in the
|
||||
// hermetically-testable Layout.RowTemplateResolver (see its own
|
||||
// class doc) — this closure supplies only the two DAT-touching
|
||||
// primitives (import, build) and keeps the shared lock at the
|
||||
// production call site. Feeds Friends/Squelch's row families (FA3)
|
||||
// AND Fellowship's (FA4, 0x21000030/0x10000281) through the SAME
|
||||
// instance/cache — all three are UiTemplateListBox row families on
|
||||
// the one social panel.
|
||||
var rowTemplates = new Layout.RowTemplateResolver(
|
||||
(layoutId, elementId) => LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, layoutId, elementId),
|
||||
info =>
|
||||
{
|
||||
var key = (templateLayoutId, templateElementId);
|
||||
if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info))
|
||||
{
|
||||
info = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, templateLayoutId, templateElementId);
|
||||
rowTemplateCache[key] = info;
|
||||
}
|
||||
if (info is null) return null;
|
||||
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
return LayoutImporter.Build(
|
||||
info,
|
||||
|
|
@ -2662,19 +2719,53 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve).Root;
|
||||
}
|
||||
});
|
||||
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return rowTemplates.Resolve(templateLayoutId, templateElementId);
|
||||
}
|
||||
|
||||
// Shared across every bind-time ResolveString call below (2
|
||||
// captions + 4x2 checkbox label/tooltip) so its internal
|
||||
// per-table-id cache actually helps rather than re-reading the same
|
||||
// StringTable object ten times.
|
||||
var fellowshipStrings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
|
||||
var callbacks = new Layout.SocialPanelController.Callbacks(
|
||||
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
|
||||
FellowshipSnapshot: _bindings.Social.FellowshipSnapshot,
|
||||
Fellowship: new Layout.SocialFellowshipPageController.Bindings(
|
||||
Snapshot: _bindings.Social.FellowshipSnapshot,
|
||||
Members: _bindings.Social.FellowshipMembers,
|
||||
TemplateResolver: TemplateResolver,
|
||||
Create: _bindings.Social.FellowshipCreate,
|
||||
Recruit: _bindings.Social.FellowshipRecruit,
|
||||
Dismiss: _bindings.Social.FellowshipDismiss,
|
||||
Quit: _bindings.Social.FellowshipQuit,
|
||||
AssignLeader: _bindings.Social.FellowshipAssignLeader,
|
||||
SetOpen: _bindings.Social.FellowshipSetOpen,
|
||||
SetPanelOpen: _bindings.Social.FellowshipSetPanelOpen,
|
||||
Selection: _bindings.Social.Selection,
|
||||
LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
|
||||
CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
|
||||
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
|
||||
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
|
||||
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
|
||||
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
|
||||
Friends: _bindings.Social.Friends,
|
||||
Squelch: _bindings.Social.Squelch,
|
||||
TemplateResolver: TemplateResolver);
|
||||
|
||||
Layout.SocialPanelController? controller =
|
||||
Layout.SocialPanelController.Bind(layout, callbacks);
|
||||
// A second DatLock scope (MountOptionsPanel's own precedent, above):
|
||||
// SocialFellowshipPageController.Bind resolves the Open/Close
|
||||
// caption pair and all four checkbox label/tooltip strings at BIND
|
||||
// TIME (never per-tick — see that class's own doc on why), and
|
||||
// Friends/Squelch/Fellowship's row-building all reach the SAME
|
||||
// DatLock through TemplateResolver above (re-entrant on this
|
||||
// thread — Monitor.Enter is safe to re-acquire recursively).
|
||||
Layout.SocialPanelController? controller;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
controller = Layout.SocialPanelController.Bind(layout, callbacks);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
|
||||
|
|
|
|||
|
|
@ -266,4 +266,35 @@ public sealed class UiTemplateListBox : UiDatElement
|
|||
/// </para>
|
||||
/// </summary>
|
||||
public void Flush() => _viewport?.ClearContent();
|
||||
|
||||
/// <summary>
|
||||
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like
|
||||
/// <see cref="Flush"/>, but preserves the CURRENT scroll offset instead
|
||||
/// of resetting it to 0. <see cref="Flush"/>'s <c>ClearContent</c> zeroes
|
||||
/// <see cref="UiScrollablePanel.Scroll"/>'s position — fine for
|
||||
/// Friends/Squelch (revision-polled rebuild while visible, no mid-scroll
|
||||
/// disturbance concern documented) but wrong for a roster whose rebuild
|
||||
/// can be driven by a per-vitals-tick revision bump: resetting to the
|
||||
/// top on every incoming <c>0x02C0</c> would fight the user's own
|
||||
/// scrolling the instant they touch the fellowship list.
|
||||
/// <see cref="SocialFellowshipPageController"/> only calls this method
|
||||
/// when the member SET actually changed (join/leave/disband) — a plain
|
||||
/// vitals/stat refresh updates the existing rows' bound widgets in place
|
||||
/// and never touches the ListBox structure at all, so this path is the
|
||||
/// rare case, not the common one. The restored offset is clamped against
|
||||
/// the STALE (pre-rebuild) content height at the moment of the call;
|
||||
/// <see cref="UiScrollablePanel.LayoutScrollableChildren"/> re-clamps it
|
||||
/// against the fresh height on the very next draw, before anything is
|
||||
/// ever painted with a stale bound — the same "clamp now, correct at
|
||||
/// next layout" contract <see cref="UiScrollablePanel.RecomputeContentHeight"/>
|
||||
/// already relies on for every ordinary <see cref="AddItemFromTemplateList"/>
|
||||
/// call. A no-op (same as <see cref="Flush"/>) while dormant.
|
||||
/// </summary>
|
||||
public void FlushPreservingScroll()
|
||||
{
|
||||
if (_viewport is null) return;
|
||||
int savedScrollY = _viewport.Scroll.ScrollY;
|
||||
_viewport.ClearContent();
|
||||
_viewport.Scroll.SetScrollY(savedScrollY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue