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
|
// directly (the panel's read-only rows need full-collection
|
||||||
// enumeration, not the bot-facing IRuntimeSocialView's
|
// enumeration, not the bot-facing IRuntimeSocialView's
|
||||||
// per-id lookup).
|
// 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(
|
Social: new SocialRuntimeBindings(
|
||||||
() => d.Runtime.Fellowship.Snapshot,
|
() => d.Runtime.Fellowship.Snapshot,
|
||||||
() => d.Runtime.Allegiance.Snapshot,
|
() => d.Runtime.Allegiance.Snapshot,
|
||||||
d.Communication.Friends,
|
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,
|
StackSplitQuantity: d.StackSplitQuantity,
|
||||||
Plugins: d.UiRegistry,
|
Plugins: d.UiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,46 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
generation,
|
generation,
|
||||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
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()
|
public void Deactivate()
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
|
||||||
/// steps 13-18, written after the code landed):
|
/// steps 13-18, written after the code landed):
|
||||||
///
|
///
|
||||||
/// <list type="bullet">
|
/// <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 —
|
/// consumer):</b> Group B rows actually bound —
|
||||||
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
||||||
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
||||||
|
|
@ -122,10 +122,17 @@ public static class CharacterOptionsPageController
|
||||||
/// <c>DisableDistanceFog</c>/<c>DisplayTimeStamps</c>/<c>ToggleRun</c>
|
/// <c>DisableDistanceFog</c>/<c>DisplayTimeStamps</c>/<c>ToggleRun</c>
|
||||||
/// (Group B, polled at <c>GameWindow.cs:655-680</c>),
|
/// (Group B, polled at <c>GameWindow.cs:655-680</c>),
|
||||||
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
|
/// <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>
|
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
|
||||||
/// gates every Turbine room join on the matching bit).</description></item>
|
/// gates every Turbine room join on the matching bit), and — landed at
|
||||||
/// <item><description><b>Dimmed (35 rows, store-only):</b> every
|
/// 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
|
/// 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
|
/// consumer) and every remaining Group B row the OP4 gate script's own
|
||||||
/// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>,
|
/// 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>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
|
||||||
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
||||||
/// deferral (<c>SalvageMultiple</c>,
|
/// deferral (<c>SalvageMultiple</c>,
|
||||||
/// <c>DisableHouseRestrictionEffects</c>, <c>FellowshipShareXP</c>,
|
/// <c>DisableHouseRestrictionEffects</c>,
|
||||||
/// <c>HearPkDeathMessages</c>).</description></item>
|
/// <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
|
/// <item><description><b>Flagged ambiguity, resolved by code evidence
|
||||||
/// (see final report, not re-litigated here):</b> the research doc's
|
/// (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
|
/// §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
|
/// not a behavioral reader — ACE alone decides whether to honor corpse
|
||||||
/// looting (<c>Player_Death.cs:755</c>). Classified Group A / dimmed.
|
/// looting (<c>Player_Death.cs:755</c>). Classified Group A / dimmed.
|
||||||
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
|
/// <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
|
/// (<c>RuntimeCharacterState.TrySetOption</c>, MF-2) that the research
|
||||||
/// doc's Group A classification does not capture (the doc predates
|
/// doc's Group A classification did not capture, kept dimmed at OP1
|
||||||
/// OP1's review-fix round) — kept dimmed per the doc's explicit Group A
|
/// pending "a future un-dim is a one-line judgment call, not a
|
||||||
/// listing and this task's instruction to treat the doc as the sole
|
/// re-investigation" — that future consumer landed at Campaign FA slice
|
||||||
/// authority rather than infer from code comments; a future un-dim is a
|
/// FA4 (see D7's addendum to register row AD-78), so both rows moved to
|
||||||
/// one-line judgment call, not a re-investigation.</description></item>
|
/// the NOT-dimmed bullet above, together with the two
|
||||||
|
/// <c>FellowshipShare*</c> rows FA4 also gave real consumers.</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const bool Live = false;
|
private const bool Live = false;
|
||||||
|
|
@ -192,12 +202,12 @@ public static class CharacterOptionsPageController
|
||||||
}),
|
}),
|
||||||
new("ID_CharacterOption_Grouping_Section", new RowSpec[]
|
new("ID_CharacterOption_Grouping_Section", new RowSpec[]
|
||||||
{
|
{
|
||||||
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A
|
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
|
||||||
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note)
|
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
|
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
|
||||||
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D
|
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", StoreOnly), // Group A
|
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", StoreOnly), // Group A (see ambiguity note)
|
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[]
|
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;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap —
|
/// Campaign FA slice FA4: the Fellowship page fully live — roster rows, the
|
||||||
/// the ONLY behavior this SHELL slice owns for this page (roster rows,
|
/// <c>0x00A6</c> panel-open/vitals-stream gate (D4), the inline create flow,
|
||||||
/// vitals, create dialog, and the option row un-dims are FA4 scope).
|
/// 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>
|
/// <para>
|
||||||
/// Retail <c>gmFellowshipUI::Update @0x0048F440</c>
|
/// <b>Roster rebuild discipline — FA3 carry-forward 1.</b>
|
||||||
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
|
/// <see cref="RuntimeFellowshipState"/> bumps ONE monotonic revision for
|
||||||
/// <c>m_pNotInAFellowshipFrame</c> (<c>0x1000026B</c>) shows and
|
/// every mutation, including a pure vitals refresh
|
||||||
/// <c>m_pInAFellowshipFrame</c> (<c>0x10000275</c>) hides when
|
/// (<c>0x02C0</c> with the same member set — lane B §2.6/§4.5, the
|
||||||
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
|
/// <c>0x00A6</c>-gated stream this same slice turns on). A naive
|
||||||
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
|
/// "revision changed → <c>Flush</c> + rebuild" binding (the Friends/Squelch
|
||||||
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
|
/// shape) would therefore tear down and rebuild the whole ListBox on every
|
||||||
/// button, and all FOUR visible option checkboxes (Ignore Fellowship
|
/// incoming vitals tick while the panel is open — resetting the user's
|
||||||
/// Requests / Auto-Accept Requests / Share XP / Share Loot —
|
/// scroll position to the top every time (<see cref="UiTemplateListBox.Flush"/>
|
||||||
/// <c>0x10000270</c>–<c>0x10000273</c>, fix-round mechanism SF-4); <c>0x10000275</c> holds
|
/// zeroes the scroll offset) and re-resolving nine DAT row templates for no
|
||||||
/// the roster list and all six member-management buttons. A single
|
/// structural reason. <see cref="RefreshRoster"/> instead diffs the member
|
||||||
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the
|
/// GUID SET: when it is unchanged, existing rows are updated IN PLACE
|
||||||
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
|
/// (<see cref="UpdateRow"/> — no ListBox mutation at all, so scroll position
|
||||||
/// unknown U6).
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SocialFellowshipPageController
|
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 _notInFellowshipFrame;
|
||||||
private readonly UiElement _inFellowshipFrame;
|
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(
|
private SocialFellowshipPageController(
|
||||||
UiElement notInFellowshipFrame,
|
UiElement notInFellowshipFrame,
|
||||||
UiElement inFellowshipFrame,
|
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;
|
_notInFellowshipFrame = notInFellowshipFrame;
|
||||||
_inFellowshipFrame = inFellowshipFrame;
|
_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(
|
public static SocialFellowshipPageController? Bind(UiElement pageRoot, Bindings bindings)
|
||||||
UiElement pageRoot,
|
|
||||||
Func<RuntimeFellowshipSnapshot> snapshot)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pageRoot);
|
ArgumentNullException.ThrowIfNull(pageRoot);
|
||||||
ArgumentNullException.ThrowIfNull(snapshot);
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
|
||||||
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|
||||||
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
|
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
|
||||||
|
|
@ -58,23 +236,446 @@ public sealed class SocialFellowshipPageController
|
||||||
return null;
|
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();
|
controller.Tick();
|
||||||
return controller;
|
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>
|
/// <summary>
|
||||||
/// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
|
/// Re-reads live state and applies retail's frame swap, roster diff,
|
||||||
/// bool writes) — called every frame from
|
/// button-enable rules, and checkbox seeding. Called every frame from
|
||||||
/// <see cref="SocialPanelController.Tick"/> rather than gated on
|
/// <see cref="SocialPanelController.Tick"/>, matching every other
|
||||||
/// page-shown/revision, since <see cref="UiElement.Visible"/> has no
|
/// retained panel's empty-state gate (see FA3's own doc for why this
|
||||||
/// data-driven provider mechanism of its own (unlike <see cref="UiText.LinesProvider"/>,
|
/// stays unconditional rather than visibility-gated: two bool writes
|
||||||
/// which is polled by the render loop already).
|
/// plus, at most, a revision-gated roster diff — no DAT access).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Tick()
|
public void Tick()
|
||||||
{
|
{
|
||||||
bool inFellowship = _snapshot().IsInFellowship;
|
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
|
||||||
|
bool inFellowship = snapshot.IsInFellowship;
|
||||||
_notInFellowshipFrame.Visible = !inFellowship;
|
_notInFellowshipFrame.Visible = !inFellowship;
|
||||||
_inFellowshipFrame.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>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x10000517</c>
|
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
||||||
/// authors a direct sibling scrollbar, <c>0x10000518</c> — wired to
|
/// ListBox <c>0x10000517</c> authors a direct sibling scrollbar,
|
||||||
/// <see cref="UiTemplateListBox.Scroll"/> the SAME way every other
|
/// <c>0x10000518</c> — wired to <see cref="UiTemplateListBox.Scroll"/> the
|
||||||
/// <c>UiTemplateListBox</c> consumer wires its own scrollbar
|
/// SAME way every other <c>UiTemplateListBox</c> consumer wires its own
|
||||||
/// (<c>CharacterOptionsPageController</c>/<c>ChatOptionsPageController</c>/
|
/// scrollbar. Resolved via the built <see cref="UiTemplateListBox.ScrollbarElementId"/>
|
||||||
/// <c>ConfigOptionsPageController</c>/<c>KeyboardConfigController</c>).
|
/// (dat property <c>0x72</c>, populated by <see cref="ElementReader"/>)
|
||||||
/// Without it the list has NO wheel fallback (<see cref="UiScrollablePanel"/>
|
/// rather than the hardcoded literal a prior revision of this file used —
|
||||||
/// has no wheel handler) and is completely unreachable past the box's
|
/// same "prefer the DAT field over a duplicated constant" fix
|
||||||
/// visible extent.
|
/// <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>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController
|
||||||
{
|
{
|
||||||
private const uint ListBoxId = 0x10000517u;
|
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 UiTemplateListBox _listBox;
|
||||||
private readonly FriendsState _friends;
|
private readonly FriendsState _friends;
|
||||||
private long _lastRevision = long.MinValue;
|
private long _lastRevision = long.MinValue;
|
||||||
|
|
@ -95,11 +97,15 @@ public sealed class SocialFriendsPageController
|
||||||
}
|
}
|
||||||
listBox.TemplateResolver = templateResolver;
|
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;
|
scrollbar.Model = listBox.Scroll;
|
||||||
else
|
else
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
$"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
|
||||||
+ "not found — the Friends list will not scroll.");
|
+ "not found — the Friends list will not scroll.");
|
||||||
|
|
||||||
var controller = new SocialFriendsPageController(listBox, friends);
|
var controller = new SocialFriendsPageController(listBox, friends);
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,15 @@ public sealed class SocialPanelController : IRetainedPanelController
|
||||||
private const uint CloseButtonId = 0x10000290u;
|
private const uint CloseButtonId = 0x10000290u;
|
||||||
|
|
||||||
/// <summary>Callback delegates + live-state accessors this controller
|
/// <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(
|
public sealed record Callbacks(
|
||||||
Action Toggle,
|
Action Toggle,
|
||||||
Func<RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
SocialFellowshipPageController.Bindings Fellowship,
|
||||||
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
||||||
FriendsState Friends,
|
FriendsState Friends,
|
||||||
SquelchState Squelch,
|
SquelchState Squelch,
|
||||||
|
|
@ -122,6 +127,17 @@ public sealed class SocialPanelController : IRetainedPanelController
|
||||||
_allegiance = allegiance;
|
_allegiance = allegiance;
|
||||||
_friends = friends;
|
_friends = friends;
|
||||||
_squelch = squelch;
|
_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>
|
/// <summary>
|
||||||
|
|
@ -156,7 +172,7 @@ public sealed class SocialPanelController : IRetainedPanelController
|
||||||
|
|
||||||
SocialFellowshipPageController? fellowship = fellowshipPage is null
|
SocialFellowshipPageController? fellowship = fellowshipPage is null
|
||||||
? null
|
? null
|
||||||
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot);
|
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
|
||||||
SocialAllegiancePageController? allegiance = allegiancePage is null
|
SocialAllegiancePageController? allegiance = allegiancePage is null
|
||||||
? null
|
? null
|
||||||
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
|
: 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
|
/// window manager on every hidden-to-shown transition (fix-round blast
|
||||||
/// SF-2). Does not force an immediate Friends/Squelch rebuild — the
|
/// SF-2). Does not force an immediate Friends/Squelch rebuild — the
|
||||||
/// next <see cref="Tick"/> naturally picks up any revision bump that
|
/// next <see cref="Tick"/> naturally picks up any revision bump that
|
||||||
/// accumulated while hidden.</summary>
|
/// accumulated while hidden. Also drives D4 (see the constructor's own
|
||||||
public void OnShown() => _visible = true;
|
/// doc on <see cref="UiTabPanel.ActivePageChanged"/>).</summary>
|
||||||
|
public void OnShown()
|
||||||
|
{
|
||||||
|
_visible = true;
|
||||||
|
UpdateFellowshipPageVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
|
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
|
||||||
/// shown-to-hidden transition (fix-round blast SF-2).</summary>
|
/// shown-to-hidden transition (fix-round blast SF-2). Also drives D4.</summary>
|
||||||
public void OnHidden() => _visible = false;
|
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>
|
/// <summary>
|
||||||
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
|
/// 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
|
/// <b>Fix-round blast SF-2.</b> The Friends/Squelch rebuild
|
||||||
/// (<see cref="SocialFriendsPageController.Tick"/>/
|
/// (<see cref="SocialFriendsPageController.Tick"/>/
|
||||||
/// <see cref="SocialSquelchPageController.Tick"/>) takes the shared DAT
|
/// <see cref="SocialSquelchPageController.Tick"/>) takes the shared DAT
|
||||||
/// lock per row template resolve — it only runs while <see cref="_visible"/>
|
/// lock per row template resolve on EVERY revision change — it only
|
||||||
/// is true. Fellowship/Allegiance stay unconditional: their own
|
/// runs while <see cref="_visible"/> is true. Fellowship/Allegiance stay
|
||||||
/// <c>Tick()</c> methods are two bool/delegate writes with no DAT
|
/// unconditional: Allegiance's own <c>Tick()</c> is two bool/delegate
|
||||||
/// access, cheap enough to keep polling every frame the way every other
|
/// writes with no DAT access; Fellowship's (FA4) is revision-GATED
|
||||||
/// retained panel's empty-state gate already does.
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Tick()
|
public void Tick()
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,14 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x1000053E</c>
|
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
||||||
/// authors a direct sibling scrollbar, <c>0x10000543</c> — wired to
|
/// 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="UiTemplateListBox.Scroll"/> the same way
|
||||||
/// <see cref="SocialFriendsPageController"/> wires its own (see that
|
/// <see cref="SocialFriendsPageController"/> wires its own (see that
|
||||||
/// class's doc for the full rationale, including why there is no wheel
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -52,9 +54,6 @@ public sealed class SocialSquelchPageController
|
||||||
{
|
{
|
||||||
private const uint ListBoxId = 0x1000053Eu;
|
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 UiTemplateListBox _listBox;
|
||||||
private readonly SquelchState _squelch;
|
private readonly SquelchState _squelch;
|
||||||
private long _lastRevision = long.MinValue;
|
private long _lastRevision = long.MinValue;
|
||||||
|
|
@ -83,11 +82,15 @@ public sealed class SocialSquelchPageController
|
||||||
}
|
}
|
||||||
listBox.TemplateResolver = templateResolver;
|
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;
|
scrollbar.Model = listBox.Scroll;
|
||||||
else
|
else
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
$"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
|
||||||
+ "not found — the Squelch list will not scroll.");
|
+ "not found — the Squelch list will not scroll.");
|
||||||
|
|
||||||
var controller = new SocialSquelchPageController(listBox, squelch);
|
var controller = new SocialSquelchPageController(listBox, squelch);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Core.Input;
|
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
|
/// the panel's read-only rows need the actual entries, same access shape
|
||||||
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
|
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
|
||||||
/// already exposes for every other Communication-owned consumer).
|
/// 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>
|
/// </summary>
|
||||||
public sealed record SocialRuntimeBindings(
|
public sealed record SocialRuntimeBindings(
|
||||||
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
||||||
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
||||||
AcDream.Core.Social.FriendsState Friends,
|
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(
|
public sealed record InventoryRuntimeBindings(
|
||||||
ClientObjectTable Objects,
|
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)
|
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)
|
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
|
||||||
=> _gameplayConfirmationController?.HandleDone(done) == true;
|
=> _gameplayConfirmationController?.HandleDone(done) == true;
|
||||||
|
|
@ -2641,20 +2699,19 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
// resolvers read the shared, non-thread-safe DatCollection) because
|
// resolvers read the shared, non-thread-safe DatCollection) because
|
||||||
// each row needs its OWN UiElement instance — but the expensive
|
// each row needs its OWN UiElement instance — but the expensive
|
||||||
// per-row DAT tree walk is gone after the first resolve.
|
// per-row DAT tree walk is gone after the first resolve.
|
||||||
var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>();
|
// FA4 carry-forward 2: the caching logic itself now lives in the
|
||||||
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
|
// hermetically-testable Layout.RowTemplateResolver (see its own
|
||||||
{
|
// class doc) — this closure supplies only the two DAT-touching
|
||||||
lock (_bindings.Assets.DatLock)
|
// 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);
|
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||||
return LayoutImporter.Build(
|
return LayoutImporter.Build(
|
||||||
info,
|
info,
|
||||||
|
|
@ -2662,19 +2719,53 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.ResolveFont,
|
_bindings.Assets.ResolveFont,
|
||||||
strings.Resolve).Root;
|
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(
|
var callbacks = new Layout.SocialPanelController.Callbacks(
|
||||||
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
|
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,
|
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
|
||||||
Friends: _bindings.Social.Friends,
|
Friends: _bindings.Social.Friends,
|
||||||
Squelch: _bindings.Social.Squelch,
|
Squelch: _bindings.Social.Squelch,
|
||||||
TemplateResolver: TemplateResolver);
|
TemplateResolver: TemplateResolver);
|
||||||
|
|
||||||
Layout.SocialPanelController? controller =
|
// A second DatLock scope (MountOptionsPanel's own precedent, above):
|
||||||
Layout.SocialPanelController.Bind(layout, callbacks);
|
// 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)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
|
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
|
||||||
|
|
|
||||||
|
|
@ -266,4 +266,35 @@ public sealed class UiTemplateListBox : UiDatElement
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Flush() => _viewport?.ClearContent();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -698,14 +698,19 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The exact 35 <see cref="CharacterOptionId"/> values this slice dims
|
/// The exact 31 <see cref="CharacterOptionId"/> values this slice dims
|
||||||
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
|
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
|
||||||
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
|
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
|
||||||
/// table in that class's own doc comment. <b>Wiring a future consumer
|
/// table in that class's own doc comment. <b>Wiring a future consumer
|
||||||
/// for any of these means removing it from this literal set AND
|
/// for any of these means removing it from this literal set AND
|
||||||
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
|
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
|
||||||
/// <c>Live</c> consciously — leaving either one stale fails this
|
/// <c>Live</c> consciously — leaving either one stale fails this
|
||||||
/// test.</b>
|
/// test.</b> Campaign FA slice FA4, D7 removed four ids from this set —
|
||||||
|
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
|
||||||
|
/// (the fellowship-invite auto-decline/auto-accept, D6) and
|
||||||
|
/// <c>FellowshipShareXP</c>/<c>FellowshipShareLoot</c> (the fellowship
|
||||||
|
/// page's Create flow / D5 display / second checkbox surface) all
|
||||||
|
/// gained real acdream-side consumers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
|
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
|
||||||
[
|
[
|
||||||
|
|
@ -724,13 +729,9 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
CharacterOptionId.FilterLanguage,
|
CharacterOptionId.FilterLanguage,
|
||||||
CharacterOptionId.ShowHelm,
|
CharacterOptionId.ShowHelm,
|
||||||
CharacterOptionId.ShowCloak,
|
CharacterOptionId.ShowCloak,
|
||||||
// Group 3 (Grouping) — all 6
|
// Group 3 (Grouping) — 2 of 6 (FA4, D7: the other four — see class doc)
|
||||||
CharacterOptionId.IgnoreAllegianceRequests,
|
CharacterOptionId.IgnoreAllegianceRequests,
|
||||||
CharacterOptionId.IgnoreFellowshipRequests,
|
|
||||||
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
||||||
CharacterOptionId.FellowshipShareXP,
|
|
||||||
CharacterOptionId.FellowshipShareLoot,
|
|
||||||
CharacterOptionId.FellowshipAutoAcceptRequests,
|
|
||||||
// Group 4 (Other Players) — 10 of 11
|
// Group 4 (Other Players) — 10 of 11
|
||||||
CharacterOptionId.AcceptLootPermits,
|
CharacterOptionId.AcceptLootPermits,
|
||||||
CharacterOptionId.UseDeception,
|
CharacterOptionId.UseDeception,
|
||||||
|
|
@ -761,8 +762,8 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
||||||
Assert.Equal(35, actualStoreOnly.Count);
|
Assert.Equal(31, actualStoreOnly.Count);
|
||||||
Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows
|
Assert.Equal(19, 50 - actualStoreOnly.Count); // the 19 live rows (FA4, D7: +4)
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA4, carry-forward 2 (the FA3 re-review's non-blocking
|
||||||
|
/// finding: "add a production-resolver test for the new template cache").
|
||||||
|
/// <see cref="RowTemplateResolver"/> is the extracted, hermetically-testable
|
||||||
|
/// caching resolver <c>RetailUiRuntime.MountSocialPanel</c> actually uses in
|
||||||
|
/// production (Friends/Squelch since FA3, Fellowship since FA4) — these
|
||||||
|
/// tests exercise ITS class directly, with fake import/build delegates, no
|
||||||
|
/// live DAT.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RowTemplateResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_CachesTheImport_ButBuildsAFreshWidgetEveryCall()
|
||||||
|
{
|
||||||
|
int importCalls = 0;
|
||||||
|
int buildCalls = 0;
|
||||||
|
var resolver = new RowTemplateResolver(
|
||||||
|
importInfos: (layoutId, elementId) =>
|
||||||
|
{
|
||||||
|
importCalls++;
|
||||||
|
return new ElementInfo { Id = elementId, Type = 0xCu };
|
||||||
|
},
|
||||||
|
build: info =>
|
||||||
|
{
|
||||||
|
buildCalls++;
|
||||||
|
return new UiText();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
UiElement? third = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(second);
|
||||||
|
Assert.NotNull(third);
|
||||||
|
// The expensive DAT tree walk (ImportInfos) ran exactly once...
|
||||||
|
Assert.Equal(1, importCalls);
|
||||||
|
Assert.Equal(1, resolver.ImportCount);
|
||||||
|
// ...but every row still gets its OWN built widget instance (a
|
||||||
|
// shared instance would mean every fellow's row points at the same
|
||||||
|
// UiElement, breaking per-row state like OnClick/LinesProvider).
|
||||||
|
Assert.Equal(3, buildCalls);
|
||||||
|
Assert.NotSame(first, second);
|
||||||
|
Assert.NotSame(second, third);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_DifferentTemplatePairs_ImportIndependently()
|
||||||
|
{
|
||||||
|
int importCalls = 0;
|
||||||
|
var resolver = new RowTemplateResolver(
|
||||||
|
importInfos: (layoutId, elementId) =>
|
||||||
|
{
|
||||||
|
importCalls++;
|
||||||
|
return new ElementInfo { Id = elementId, Type = 0xCu };
|
||||||
|
},
|
||||||
|
build: _ => new UiText());
|
||||||
|
|
||||||
|
// Fellowship's own template pair, and a DIFFERENT one (Friends'),
|
||||||
|
// through the SAME resolver instance — matches production, where
|
||||||
|
// MountSocialPanel feeds all three row families through one
|
||||||
|
// RowTemplateResolver.
|
||||||
|
resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
resolver.Resolve(0x2100005Du, 0x10000519u);
|
||||||
|
resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
resolver.Resolve(0x2100005Du, 0x10000519u);
|
||||||
|
|
||||||
|
Assert.Equal(2, importCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ImportMiss_CachesTheNull_AndNeverRetriesTheImport()
|
||||||
|
{
|
||||||
|
int importCalls = 0;
|
||||||
|
var resolver = new RowTemplateResolver(
|
||||||
|
importInfos: (_, _) => { importCalls++; return null; },
|
||||||
|
build: _ => throw new InvalidOperationException("build must not run for a null import"));
|
||||||
|
|
||||||
|
UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||||
|
|
||||||
|
Assert.Null(first);
|
||||||
|
Assert.Null(second);
|
||||||
|
Assert.Equal(1, importCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,555 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA4: hermetic (no DAT, no live runtime) tests for
|
||||||
|
/// <see cref="SocialFellowshipPageController"/> against a hand-built page
|
||||||
|
/// tree carrying every element id lane A's structural inventory names
|
||||||
|
/// (docs/research/2026-08-11-fa-panel-structure.md §3.1).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SocialFellowshipPageControllerTests
|
||||||
|
{
|
||||||
|
private const uint NotInFellowshipFrameId = 0x1000026Bu;
|
||||||
|
private const uint InFellowshipFrameId = 0x10000275u;
|
||||||
|
private const uint NameEntryBoxId = 0x1000026Fu;
|
||||||
|
private const uint CreateButtonId = 0x10000274u;
|
||||||
|
private const uint FellowshipNameTextId = 0x10000276u;
|
||||||
|
private const uint ListBoxId = 0x10000279u;
|
||||||
|
private const uint ScrollbarId = 0x1000027Au;
|
||||||
|
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 IgnoreCheckboxId = 0x10000270u;
|
||||||
|
private const uint AutoAcceptCheckboxId = 0x10000271u;
|
||||||
|
private const uint ShareXpCheckboxId = 0x10000272u;
|
||||||
|
private const uint ShareLootCheckboxId = 0x10000273u;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private static readonly Func<uint, (uint, int, int)> NoTex = static _ => (0u, 0, 0);
|
||||||
|
|
||||||
|
private static UiButton MakeButton(uint id)
|
||||||
|
{
|
||||||
|
var button = new UiButton(new ElementInfo { Id = id, Type = 1u }, NoTex);
|
||||||
|
button.DatElementId = id;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiElement BuildPageRoot(out UiTemplateListBox listBox, out UiField nameField)
|
||||||
|
{
|
||||||
|
var root = new UiPanel();
|
||||||
|
void AddPlain(UiElement child, uint id)
|
||||||
|
{
|
||||||
|
child.DatElementId = id;
|
||||||
|
root.AddChild(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddPlain(new UiPanel(), NotInFellowshipFrameId);
|
||||||
|
AddPlain(new UiPanel(), InFellowshipFrameId);
|
||||||
|
|
||||||
|
nameField = new UiField();
|
||||||
|
AddPlain(nameField, NameEntryBoxId);
|
||||||
|
|
||||||
|
AddPlain(MakeButton(CreateButtonId), CreateButtonId);
|
||||||
|
AddPlain(new UiText(), FellowshipNameTextId);
|
||||||
|
|
||||||
|
listBox = new UiTemplateListBox(
|
||||||
|
new ElementInfo { Id = ListBoxId, Type = 5u },
|
||||||
|
NoTex,
|
||||||
|
new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
|
||||||
|
scrollbarElementId: ScrollbarId);
|
||||||
|
AddPlain(listBox, ListBoxId);
|
||||||
|
|
||||||
|
AddPlain(new UiScrollbar(), ScrollbarId);
|
||||||
|
|
||||||
|
AddPlain(MakeButton(LeaderButtonId), LeaderButtonId);
|
||||||
|
AddPlain(MakeButton(QuitButtonId), QuitButtonId);
|
||||||
|
AddPlain(MakeButton(OpenButtonId), OpenButtonId);
|
||||||
|
AddPlain(MakeButton(RecruitButtonId), RecruitButtonId);
|
||||||
|
AddPlain(MakeButton(DismissButtonId), DismissButtonId);
|
||||||
|
AddPlain(MakeButton(DisbandButtonId), DisbandButtonId);
|
||||||
|
AddPlain(MakeButton(IgnoreCheckboxId), IgnoreCheckboxId);
|
||||||
|
AddPlain(MakeButton(AutoAcceptCheckboxId), AutoAcceptCheckboxId);
|
||||||
|
AddPlain(MakeButton(ShareXpCheckboxId), ShareXpCheckboxId);
|
||||||
|
AddPlain(MakeButton(ShareLootCheckboxId), ShareLootCheckboxId);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors lane A's 8-child fellow row shape (name, stats, three
|
||||||
|
/// meter+text-consumed-by-meter pairs — the meter text is NOT a separate
|
||||||
|
/// resolved child, per <see cref="UiMeter.ConsumesDatChildren"/>).</summary>
|
||||||
|
private static UiElement BuildFakeFellowRow()
|
||||||
|
{
|
||||||
|
var row = new UiPanel();
|
||||||
|
void Add(UiElement child, uint id)
|
||||||
|
{
|
||||||
|
child.DatElementId = id;
|
||||||
|
row.AddChild(child);
|
||||||
|
}
|
||||||
|
Add(new UiText(), RowNameTextId);
|
||||||
|
Add(new UiText(), RowStatsTextId);
|
||||||
|
Add(new UiMeter(), RowHealthMeterId);
|
||||||
|
Add(new UiMeter(), RowStaminaMeterId);
|
||||||
|
Add(new UiMeter(), RowManaMeterId);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int _rowBuildCount;
|
||||||
|
|
||||||
|
private static UiElement? FakeRowResolver(uint layoutId, uint elementId)
|
||||||
|
{
|
||||||
|
_rowBuildCount++;
|
||||||
|
return BuildFakeFellowRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default);
|
||||||
|
|
||||||
|
private sealed class FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
public RuntimeFellowshipSnapshot Snapshot;
|
||||||
|
public List<RuntimeFellowMemberSnapshot> Members = [];
|
||||||
|
public readonly List<string> Calls = [];
|
||||||
|
public readonly Dictionary<CharacterOptionId, bool> Options = new();
|
||||||
|
public SelectionState Selection = new();
|
||||||
|
public uint LocalPlayerGuid;
|
||||||
|
public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver;
|
||||||
|
|
||||||
|
public SocialFellowshipPageController.Bindings Build() => new(
|
||||||
|
Snapshot: () => Snapshot,
|
||||||
|
Members: () => Members,
|
||||||
|
TemplateResolver: TemplateResolver,
|
||||||
|
Create: (name, shareXp) => { Calls.Add($"create:{name}:{shareXp}"); return InactiveResult; },
|
||||||
|
Recruit: guid => { Calls.Add($"recruit:{guid:X8}"); return InactiveResult; },
|
||||||
|
Dismiss: guid => { Calls.Add($"dismiss:{guid:X8}"); return InactiveResult; },
|
||||||
|
Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; },
|
||||||
|
AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; },
|
||||||
|
SetOpen: isOpen => { Calls.Add($"set-open:{isOpen}"); return InactiveResult; },
|
||||||
|
SetPanelOpen: panelOpen => { Calls.Add($"set-panel-open:{panelOpen}"); return InactiveResult; },
|
||||||
|
Selection: Selection,
|
||||||
|
LocalPlayerGuid: () => LocalPlayerGuid,
|
||||||
|
CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
|
||||||
|
SetCharacterOption: (id, value) => { Options[id] = value; Calls.Add($"set-option:{id}:{value}"); },
|
||||||
|
ResolveString: (_, _) => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty/full frame swap (still correct after the FA4 rewrite) ────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_NotInFellowship_ShowsEmptyFrame()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
|
||||||
|
SocialFellowshipPageController? controller = SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
Assert.True(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
|
||||||
|
Assert.False(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_InFellowship_ShowsPopulatedFrame()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
|
||||||
|
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
Assert.False(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
|
||||||
|
Assert.True(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roster building ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_BuildsOneRowPerMember_WithNameLevelAndVitals()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot
|
||||||
|
{
|
||||||
|
IsInFellowship = true,
|
||||||
|
Revision = 1,
|
||||||
|
ShareXp = true,
|
||||||
|
EvenXpSplit = true,
|
||||||
|
MemberCount = 1,
|
||||||
|
},
|
||||||
|
Members =
|
||||||
|
[
|
||||||
|
new RuntimeFellowMemberSnapshot(
|
||||||
|
Guid: 0x50000001u, Name: "Alice", Level: 12,
|
||||||
|
MaxHealth: 100, MaxStamina: 80, MaxMana: 60,
|
||||||
|
CurrentHealth: 55, CurrentStamina: 80, CurrentMana: 10,
|
||||||
|
ShareLoot: true),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
|
||||||
|
Assert.Equal("Alice", name.LinesProvider().Single().Text);
|
||||||
|
|
||||||
|
UiText stats = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowStatsTextId));
|
||||||
|
// 1-member even split -> 1.0 -> "100%" (lane B §7.2 table index 0).
|
||||||
|
Assert.Equal("12 100%", stats.LinesProvider().Single().Text);
|
||||||
|
|
||||||
|
var health = Assert.IsType<UiMeter>(UiElement.FindDescendant(row, RowHealthMeterId));
|
||||||
|
Assert.Equal(0.55f, health.Fill()!.Value, 3);
|
||||||
|
Assert.Equal("55/100", health.Label());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_SameMemberSet_UpdatesRowsInPlace_NoRebuild()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var member = new RuntimeFellowMemberSnapshot(
|
||||||
|
0x50000001u, "Alice", 12, 100, 80, 60, 55, 80, 10, false);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
|
||||||
|
Members = [member],
|
||||||
|
};
|
||||||
|
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
UiElement firstRow = Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
|
||||||
|
// A vitals-only change: SAME guid, new health, revision bumped (the
|
||||||
|
// exact shape ApplyUpdateFellow produces on a 0x02C0 vitals tick).
|
||||||
|
b.Snapshot = b.Snapshot with { Revision = 2 };
|
||||||
|
b.Members = [member with { CurrentHealth = 10 }];
|
||||||
|
controller.Tick();
|
||||||
|
|
||||||
|
UiElement onlyRow = Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
Assert.Same(firstRow, onlyRow); // NOT rebuilt — same row instance
|
||||||
|
var health = Assert.IsType<UiMeter>(UiElement.FindDescendant(onlyRow, RowHealthMeterId));
|
||||||
|
Assert.Equal(0.10f, health.Fill()!.Value, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
|
||||||
|
Members = [alice],
|
||||||
|
};
|
||||||
|
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
listBox.ViewportForTest!.ApplyAnchor(listBox.Width, listBox.Height);
|
||||||
|
listBox.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
listBox.Scroll.SetScrollY(0); // only one short row — nothing to scroll, but exercise the path
|
||||||
|
|
||||||
|
// Carry-forward 1: a genuine roster change (Bob joins) must not
|
||||||
|
// silently desync — verified via row count — AND must go through
|
||||||
|
// the scroll-preserving flush, not the scroll-resetting one. We
|
||||||
|
// can't observe a NONZERO preserved offset with only one short
|
||||||
|
// row, so this test's decisive assertion is closer to the wiring:
|
||||||
|
// FlushPreservingScroll leaves Scroll.ScrollY untouched immediately
|
||||||
|
// after the clear, unlike Flush.
|
||||||
|
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
|
||||||
|
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 2 };
|
||||||
|
b.Members = [alice, bob];
|
||||||
|
controller.Tick();
|
||||||
|
|
||||||
|
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_NotInFellowship_ClearsAnyStaleRoster()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
|
||||||
|
Members = [member],
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
|
||||||
|
b.Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false };
|
||||||
|
controller.Tick();
|
||||||
|
|
||||||
|
Assert.Empty(listBox.ViewportForTest!.Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── D5 display ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, true, 1, "12 0%")] // ShareXp off -> retail's literal 0.0
|
||||||
|
[InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> round(31.11) = 31
|
||||||
|
[InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
|
||||||
|
public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected)
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot
|
||||||
|
{
|
||||||
|
IsInFellowship = true, Revision = 1, ShareXp = shareXp,
|
||||||
|
EvenXpSplit = evenSplit, MemberCount = memberCount,
|
||||||
|
},
|
||||||
|
Members = [member],
|
||||||
|
};
|
||||||
|
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
UiText stats = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowStatsTextId));
|
||||||
|
Assert.Equal(expected, stats.LinesProvider().Single().Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create flow ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateButton_DisabledWhileNameFieldEmpty_EnabledOnceTyped()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out UiField nameField);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
|
||||||
|
|
||||||
|
Assert.False(createButton.Enabled);
|
||||||
|
|
||||||
|
nameField.SetText("The Wanderers");
|
||||||
|
controller.Tick();
|
||||||
|
Assert.True(createButton.Enabled);
|
||||||
|
|
||||||
|
nameField.SetText(" ");
|
||||||
|
controller.Tick();
|
||||||
|
Assert.False(createButton.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateButton_Click_SendsTypedName_AndTheShareXpOptionValue()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out UiField nameField);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
b.Options[CharacterOptionId.FellowshipShareXP] = true;
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
|
||||||
|
|
||||||
|
nameField.SetText("The Wanderers");
|
||||||
|
createButton.OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("create:The Wanderers:True", b.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateButton_Click_WithEmptyName_DoesNotSend()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out UiField nameField);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
|
||||||
|
|
||||||
|
createButton.OnClick!();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(b.Calls, c => c.StartsWith("create:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member action buttons ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuitButton_Click_SendsQuit_WithDisbandFalse()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("quit:False", b.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisbandButton_Click_SendsQuit_WithDisbandTrue()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("quit:True", b.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenButton_Click_TogglesTheCurrentOpenState()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
|
||||||
|
LocalPlayerGuid = 1u,
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("set-open:True", b.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecruitButton_Click_SendsTheCurrentWorldSelection()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(0x60000001u, SelectionChangeSource.World);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true },
|
||||||
|
Selection = selection,
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("recruit:60000001", b.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DismissAndLeaderButtons_Click_TargetTheSelectedFellow()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||||
|
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
|
||||||
|
Members = [member],
|
||||||
|
LocalPlayerGuid = 0xFFu, // leader, so Dismiss/Leader are enabled once a fellow is selected
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
|
||||||
|
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
|
||||||
|
name.OnClick!(); // selects Alice, per SelectFellow
|
||||||
|
|
||||||
|
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
|
||||||
|
((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).OnClick!();
|
||||||
|
|
||||||
|
Assert.Contains("dismiss:50000001", b.Calls);
|
||||||
|
Assert.Contains("assign-leader:50000001", b.Calls);
|
||||||
|
Assert.Equal(0x50000001u, b.Selection.SelectedObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button enable rules (lane B §2.8) ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ButtonStates_NonLeader_DisbandOpenLeaderDismiss_AreDisabled_QuitStaysEnabled()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
|
||||||
|
LocalPlayerGuid = 0x2u, // not the leader
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
Assert.True(((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).Enabled);
|
||||||
|
Assert.False(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
|
||||||
|
Assert.False(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
|
||||||
|
Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
|
||||||
|
Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ButtonStates_Leader_DisbandAndOpen_AreEnabled()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
|
||||||
|
LocalPlayerGuid = 0x1u,
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
Assert.True(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
|
||||||
|
Assert.True(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecruitButton_DisabledWhenFellowshipIsFull()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(0x60000001u, SelectionChangeSource.World);
|
||||||
|
var b = new FellowshipBindingsBuilder
|
||||||
|
{
|
||||||
|
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, MemberCount = 9, LeaderGuid = 1u },
|
||||||
|
LocalPlayerGuid = 1u,
|
||||||
|
Selection = selection,
|
||||||
|
};
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
|
||||||
|
Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Checkboxes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Checkbox_Click_TogglesAndWritesTheCharacterOption()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
SocialFellowshipPageController.Bind(root, b.Build());
|
||||||
|
var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
|
||||||
|
Assert.False(shareXp.Selected);
|
||||||
|
|
||||||
|
shareXp.OnClick!();
|
||||||
|
|
||||||
|
Assert.True(shareXp.Selected);
|
||||||
|
Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Checkbox_RefreshesFromLiveState_WhenTheOtherSurfaceWrites()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
var ignore = (UiButton)UiElement.FindDescendant(root, IgnoreCheckboxId)!;
|
||||||
|
Assert.False(ignore.Selected);
|
||||||
|
|
||||||
|
// Simulate the Character tab's OWN checkbox writing the same option.
|
||||||
|
b.Options[CharacterOptionId.IgnoreFellowshipRequests] = true;
|
||||||
|
controller.Tick();
|
||||||
|
|
||||||
|
Assert.True(ignore.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── D4: 0x00A6 panel-open declaration ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPageVisible_SendsPanelOpen_OnlyOnATransition()
|
||||||
|
{
|
||||||
|
UiElement root = BuildPageRoot(out _, out _);
|
||||||
|
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
|
||||||
|
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||||
|
b.Calls.Clear(); // discard any Bind-time noise
|
||||||
|
|
||||||
|
controller.SetPageVisible(true);
|
||||||
|
Assert.Contains("set-panel-open:True", b.Calls);
|
||||||
|
|
||||||
|
b.Calls.Clear();
|
||||||
|
controller.SetPageVisible(true); // no transition -> no second send
|
||||||
|
Assert.Empty(b.Calls);
|
||||||
|
|
||||||
|
controller.SetPageVisible(false);
|
||||||
|
Assert.Contains("set-panel-open:False", b.Calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Social;
|
using AcDream.Core.Social;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
|
@ -16,6 +17,45 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SocialPanelControllerTests
|
public sealed class SocialPanelControllerTests
|
||||||
{
|
{
|
||||||
|
private static readonly RuntimeCommandResult InactiveResult =
|
||||||
|
new(RuntimeCommandStatus.Inactive, default);
|
||||||
|
|
||||||
|
/// <summary>Campaign FA slice FA4: the Fellowship page's full read/write
|
||||||
|
/// seam. Every command records its call (by name) into
|
||||||
|
/// <paramref name="calls"/> and returns <see cref="InactiveResult"/> —
|
||||||
|
/// hermetic tests here exercise WIRING (does clicking X call the right
|
||||||
|
/// delegate with the right argument), not live command semantics
|
||||||
|
/// (covered by <c>RuntimeFellowshipStateTests</c>/
|
||||||
|
/// <c>DirectGameRuntimeCommandAdapterTests</c>).</summary>
|
||||||
|
private static SocialFellowshipPageController.Bindings MakeFellowshipBindings(
|
||||||
|
List<string>? calls = null,
|
||||||
|
RuntimeFellowshipSnapshot snapshot = default,
|
||||||
|
IEnumerable<RuntimeFellowMemberSnapshot>? members = null,
|
||||||
|
SelectionState? selection = null,
|
||||||
|
uint localPlayerGuid = 0u,
|
||||||
|
Func<CharacterOptionId, bool>? currentCharacterOption = null,
|
||||||
|
Func<uint, uint, UiElement?>? templateResolver = null,
|
||||||
|
Func<uint, uint, string?>? resolveString = null)
|
||||||
|
{
|
||||||
|
calls ??= new List<string>();
|
||||||
|
return new SocialFellowshipPageController.Bindings(
|
||||||
|
Snapshot: () => snapshot,
|
||||||
|
Members: () => members ?? [],
|
||||||
|
TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
|
||||||
|
Create: (name, shareXp) => { calls.Add($"fellowship-create:{name}:{shareXp}"); return InactiveResult; },
|
||||||
|
Recruit: guid => { calls.Add($"fellowship-recruit:{guid:X8}"); return InactiveResult; },
|
||||||
|
Dismiss: guid => { calls.Add($"fellowship-dismiss:{guid:X8}"); return InactiveResult; },
|
||||||
|
Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; },
|
||||||
|
AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; },
|
||||||
|
SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); return InactiveResult; },
|
||||||
|
SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return InactiveResult; },
|
||||||
|
Selection: selection ?? new SelectionState(),
|
||||||
|
LocalPlayerGuid: () => localPlayerGuid,
|
||||||
|
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
|
||||||
|
SetCharacterOption: (id, value) => calls.Add($"fellowship-set-option:{id}:{value}"),
|
||||||
|
ResolveString: resolveString ?? ((_, _) => null));
|
||||||
|
}
|
||||||
|
|
||||||
private static SocialPanelController.Callbacks MakeCallbacks(
|
private static SocialPanelController.Callbacks MakeCallbacks(
|
||||||
List<string>? calls = null,
|
List<string>? calls = null,
|
||||||
RuntimeFellowshipSnapshot fellowship = default,
|
RuntimeFellowshipSnapshot fellowship = default,
|
||||||
|
|
@ -26,7 +66,7 @@ public sealed class SocialPanelControllerTests
|
||||||
calls ??= new List<string>();
|
calls ??= new List<string>();
|
||||||
return new SocialPanelController.Callbacks(
|
return new SocialPanelController.Callbacks(
|
||||||
Toggle: () => calls.Add("toggle"),
|
Toggle: () => calls.Add("toggle"),
|
||||||
FellowshipSnapshot: () => fellowship,
|
Fellowship: MakeFellowshipBindings(calls, fellowship),
|
||||||
AllegianceSnapshot: () => allegiance,
|
AllegianceSnapshot: () => allegiance,
|
||||||
Friends: friends ?? new FriendsState(),
|
Friends: friends ?? new FriendsState(),
|
||||||
Squelch: squelch ?? new SquelchState(),
|
Squelch: squelch ?? new SquelchState(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Runtime;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
|
|
||||||
|
|
@ -109,6 +110,137 @@ public sealed class SocialPanelLiveMountProbeTests
|
||||||
Assert.NotNull(el);
|
Assert.NotNull(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign FA slice FA4, item 8: the fellowship page's own control
|
||||||
|
// inventory against the PRODUCTION mount (not a fixture) — the
|
||||||
|
// name-entry field must build as UiField (proving the DAT authors
|
||||||
|
// Editable=1, SocialFellowshipPageController's load-bearing
|
||||||
|
// assumption), and every button/checkbox must resolve as UiButton.
|
||||||
|
UiElement? nameField = UiElement.FindDescendant(tabs, 0x1000026Fu);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[socialprobe] fellowship name-entry field 0x1000026F -> {(nameField is null ? "MISSING" : nameField.GetType().Name)}");
|
||||||
|
Assert.IsType<UiField>(nameField);
|
||||||
|
|
||||||
|
foreach ((uint id, string name) in new[]
|
||||||
|
{
|
||||||
|
(0x10000274u, "CreateFellowshipButton"),
|
||||||
|
(0x1000027Bu, "FellowLeaderButton"),
|
||||||
|
(0x1000027Cu, "FellowQuitButton"),
|
||||||
|
(0x1000027Du, "FellowOpenButton"),
|
||||||
|
(0x1000027Eu, "FellowRecruitButton"),
|
||||||
|
(0x1000027Fu, "FellowDismissButton"),
|
||||||
|
(0x10000280u, "FellowDisbandButton"),
|
||||||
|
(0x10000270u, "IgnoreFellowshipRequestsCheckbox"),
|
||||||
|
(0x10000271u, "FellowshipAutoAcceptRequestsCheckbox"),
|
||||||
|
(0x10000272u, "FellowshipShareXPCheckbox"),
|
||||||
|
(0x10000273u, "FellowshipShareLootCheckbox"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiElement? el = UiElement.FindDescendant(tabs, id);
|
||||||
|
Console.WriteLine($"[socialprobe] fellowship control {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
||||||
|
Assert.IsType<UiButton>(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fellowship ListBox's own authored template pair (lane A U10 —
|
||||||
|
// read P0x64 on 0x10000279) and its scrollbar (P0x72).
|
||||||
|
UiElement? fellowshipListBoxEl = UiElement.FindDescendant(tabs, 0x10000279u);
|
||||||
|
UiTemplateListBox fellowshipListBox = Assert.IsType<UiTemplateListBox>(fellowshipListBoxEl);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[socialprobe] fellowship ListBox 0x10000279 templates={fellowshipListBox.Templates.Count} "
|
||||||
|
+ $"scrollbar=0x{fellowshipListBox.ScrollbarElementId:X8}");
|
||||||
|
Assert.NotEmpty(fellowshipListBox.Templates);
|
||||||
|
UiTemplateListEntry fellowRowTemplate = fellowshipListBox.Templates[0];
|
||||||
|
|
||||||
|
// Production-resolver build of the row template (FA4 carry-forward
|
||||||
|
// 2): the SAME RowTemplateResolver shape MountSocialPanel uses in
|
||||||
|
// production — Import then Build against the LIVE dats, not a
|
||||||
|
// fixture — proving the 8-part row (lane A §3.1) actually resolves
|
||||||
|
// end-to-end, not merely that the id pair is authored.
|
||||||
|
var rowTemplates = new RowTemplateResolver(
|
||||||
|
(layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId),
|
||||||
|
info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root);
|
||||||
|
UiElement? fellowRow = rowTemplates.Resolve(
|
||||||
|
fellowRowTemplate.TemplateLayoutId, fellowRowTemplate.TemplateElementId);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[socialprobe] fellowship row template 0x{fellowRowTemplate.TemplateLayoutId:X8}/"
|
||||||
|
+ $"0x{fellowRowTemplate.TemplateElementId:X8} -> {(fellowRow is null ? "IMPORT NULL" : fellowRow.GetType().Name)}");
|
||||||
|
Assert.NotNull(fellowRow);
|
||||||
|
|
||||||
|
foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
|
||||||
|
{
|
||||||
|
(0x10000283u, "FellowName", typeof(UiText)),
|
||||||
|
(0x10000284u, "FellowStats", typeof(UiText)),
|
||||||
|
(0x10000285u, "HealthMeter", typeof(UiMeter)),
|
||||||
|
(0x10000287u, "StaminaMeter", typeof(UiMeter)),
|
||||||
|
(0x10000289u, "ManaMeter", typeof(UiMeter)),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiElement? el = UiElement.FindDescendant(fellowRow!, id);
|
||||||
|
Console.WriteLine($"[socialprobe] fellowship row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
||||||
|
Assert.IsType(expectedType, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox labels/tooltips (0x23000003) and the Open/Close caption
|
||||||
|
// pair (0x23000001) — the two string tables SocialFellowshipPageController
|
||||||
|
// resolves at Bind time (lane A §4.3).
|
||||||
|
foreach (string retailName in new[]
|
||||||
|
{
|
||||||
|
"IgnoreFellowshipRequests", "FellowshipAutoAcceptRequests",
|
||||||
|
"FellowshipShareXP", "FellowshipShareLoot",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
string? label = strings.Resolve(0x23000003u, DatStringResolver.ComputeHash($"ID_PlayerOption_{retailName}"));
|
||||||
|
Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_{retailName} -> '{label}'");
|
||||||
|
}
|
||||||
|
foreach (string key in new[]
|
||||||
|
{
|
||||||
|
"ID_Fellowship_OpenFellowshipButtonText", "ID_Fellowship_CloseFellowshipButtonText",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
string? label = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
|
||||||
|
Console.WriteLine($"[socialprobe] fellowship caption {key} -> '{label}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full end-to-end Bind against the PRODUCTION mount (the same
|
||||||
|
// resolver/scrollbar/frame wiring MountSocialPanel does) — proves
|
||||||
|
// Bind() finds every element it needs with zero "not found"
|
||||||
|
// console warnings, not just that the individual ids resolve in
|
||||||
|
// isolation above.
|
||||||
|
UiElement? fellowshipPageForBind = UiElement.FindDescendant(tabs, 0x10000292u);
|
||||||
|
Assert.NotNull(fellowshipPageForBind);
|
||||||
|
var originalOut = Console.Out;
|
||||||
|
var capture = new StringWriter();
|
||||||
|
Console.SetOut(capture);
|
||||||
|
SocialFellowshipPageController? fellowshipController;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fellowshipController = SocialFellowshipPageController.Bind(
|
||||||
|
fellowshipPageForBind!,
|
||||||
|
new SocialFellowshipPageController.Bindings(
|
||||||
|
Snapshot: () => new RuntimeFellowshipSnapshot(),
|
||||||
|
Members: () => [],
|
||||||
|
TemplateResolver: rowTemplates.Resolve,
|
||||||
|
Create: (_, _) => default,
|
||||||
|
Recruit: _ => default,
|
||||||
|
Dismiss: _ => default,
|
||||||
|
Quit: _ => default,
|
||||||
|
AssignLeader: _ => default,
|
||||||
|
SetOpen: _ => default,
|
||||||
|
SetPanelOpen: _ => default,
|
||||||
|
Selection: new AcDream.Core.Selection.SelectionState(),
|
||||||
|
LocalPlayerGuid: () => 0u,
|
||||||
|
CurrentCharacterOption: _ => false,
|
||||||
|
SetCharacterOption: (_, _) => { },
|
||||||
|
ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetOut(originalOut);
|
||||||
|
}
|
||||||
|
string bindLog = capture.ToString();
|
||||||
|
Console.WriteLine($"[socialprobe] fellowship Bind() console output:\n{bindLog}");
|
||||||
|
Assert.NotNull(fellowshipController);
|
||||||
|
Assert.DoesNotContain("not found", bindLog);
|
||||||
|
|
||||||
// Allegiance signature elements.
|
// Allegiance signature elements.
|
||||||
foreach ((uint id, string name) in new[]
|
foreach ((uint id, string name) in new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): unlike
|
||||||
|
/// <see cref="UiTemplateListBox.Flush"/>, <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||||
|
/// must not reset the scroll offset to 0 — it exists specifically so a
|
||||||
|
/// roster rebuild triggered by a server-side change (not user action)
|
||||||
|
/// doesn't yank a scrolled-down user back to the top.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiTemplateListBoxFlushPreservingScrollTests
|
||||||
|
{
|
||||||
|
private static UiTemplateListBox MakeListBox(float width, float height)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Id = 0x10000279u, Type = 5u };
|
||||||
|
return new UiTemplateListBox(
|
||||||
|
info,
|
||||||
|
_ => (0u, 0, 0),
|
||||||
|
new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
|
||||||
|
scrollbarElementId: 0x1000027Au)
|
||||||
|
{
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddRows(UiTemplateListBox box, int count, float rowHeight = 40f)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
box.AddPrebuiltRow(new UiText { Width = box.Width, Height = rowHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flush_ResetsScrollToZero_TheOrdinaryContract()
|
||||||
|
{
|
||||||
|
var box = MakeListBox(200f, 100f);
|
||||||
|
AddRows(box, 10); // 400px of content in a 100px viewport
|
||||||
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||||
|
box.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
box.Scroll.SetScrollY(150);
|
||||||
|
Assert.Equal(150, box.Scroll.ScrollY);
|
||||||
|
|
||||||
|
box.Flush();
|
||||||
|
|
||||||
|
Assert.Equal(0, box.Scroll.ScrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlushPreservingScroll_KeepsTheScrollOffset()
|
||||||
|
{
|
||||||
|
var box = MakeListBox(200f, 100f);
|
||||||
|
AddRows(box, 10); // 400px of content in a 100px viewport
|
||||||
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||||
|
box.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
box.Scroll.SetScrollY(150);
|
||||||
|
Assert.Equal(150, box.Scroll.ScrollY);
|
||||||
|
|
||||||
|
box.FlushPreservingScroll();
|
||||||
|
|
||||||
|
Assert.Equal(150, box.Scroll.ScrollY);
|
||||||
|
|
||||||
|
// Rebuild the SAME row count — the offset should still be valid
|
||||||
|
// (and stay put) once the next layout pass recomputes content
|
||||||
|
// height against the rebuilt rows.
|
||||||
|
AddRows(box, 10);
|
||||||
|
box.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
Assert.Equal(150, box.Scroll.ScrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlushPreservingScroll_ClampsToTheNewShorterContent()
|
||||||
|
{
|
||||||
|
var box = MakeListBox(200f, 100f);
|
||||||
|
AddRows(box, 10); // 400px of content
|
||||||
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||||
|
box.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
box.Scroll.SetScrollY(300); // near the bottom of the 10-row content
|
||||||
|
|
||||||
|
box.FlushPreservingScroll();
|
||||||
|
// Rebuild with far FEWER rows (e.g. most of the fellowship left).
|
||||||
|
AddRows(box, 2); // 80px of content, less than the 100px viewport
|
||||||
|
box.ViewportForTest!.LayoutScrollableChildren();
|
||||||
|
|
||||||
|
// Clamped to the new MaxScroll (0, since content < viewport) —
|
||||||
|
// never left dangling past the real content, and never throws.
|
||||||
|
Assert.Equal(0, box.Scroll.ScrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlushPreservingScroll_DormantBox_IsANoOp()
|
||||||
|
{
|
||||||
|
var box = MakeListBox(200f, 100f);
|
||||||
|
// No rows ever added — the viewport was never created.
|
||||||
|
box.FlushPreservingScroll();
|
||||||
|
Assert.Null(box.ViewportForTest);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue