diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index a9512376..aecc6c82 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -876,11 +876,28 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // directly (the panel's read-only rows need full-collection // enumeration, not the bot-facing IRuntimeSocialView's // per-id lookup). + // Campaign FA slice FA4: the seven Fellowship write commands + // route through `late.GameRuntime` (DeferredGameRuntimeStateCommands) + // — the same late-bound, generation-race-safe seam + // AddShortcut/Advance/etc already use — NOT a raw + // WorldSession.SendXxx call (see SocialRuntimeBindings' own + // FA4 doc addendum for why Quit specifically must not + // bypass the command layer). Social: new SocialRuntimeBindings( () => d.Runtime.Fellowship.Snapshot, () => d.Runtime.Allegiance.Snapshot, d.Communication.Friends, - d.Communication.Squelch), + d.Communication.Squelch, + () => d.Runtime.Fellowship.GetMembers(), + (name, shareXp) => late.GameRuntime.FellowshipCreate(name, shareXp), + guid => late.GameRuntime.FellowshipRecruit(guid), + guid => late.GameRuntime.FellowshipDismiss(guid), + disband => late.GameRuntime.FellowshipQuit(disband), + guid => late.GameRuntime.FellowshipAssignLeader(guid), + isOpen => late.GameRuntime.FellowshipSetOpen(isOpen), + panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen), + d.Actions.Selection, + () => d.PlayerIdentity.ServerGuid), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 5e5ceb54..12b410c4 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -118,6 +118,46 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── + // Same "capture view+commands under one generation" shape as every + // method above — a displaced session (reconnect mid-click) can never + // receive a stale-generation fellowship action. + + public RuntimeCommandResult FellowshipCreate(string fellowshipName, bool shareXp) => + Invoke((commands, generation) => commands.Fellowship.Create( + generation, fellowshipName, shareXp)); + + public RuntimeCommandResult FellowshipRecruit(uint targetGuid) => + Invoke((commands, generation) => commands.Fellowship.Recruit( + generation, targetGuid)); + + public RuntimeCommandResult FellowshipDismiss(uint targetGuid) => + Invoke((commands, generation) => commands.Fellowship.Dismiss( + generation, targetGuid)); + + public RuntimeCommandResult FellowshipQuit(bool disband) => + Invoke((commands, generation) => commands.Fellowship.Quit( + generation, disband)); + + public RuntimeCommandResult FellowshipAssignLeader(uint newLeaderGuid) => + Invoke((commands, generation) => commands.Fellowship.AssignLeader( + generation, newLeaderGuid)); + + public RuntimeCommandResult FellowshipSetOpen(bool isOpen) => + Invoke((commands, generation) => commands.Fellowship.SetOpen( + generation, isOpen)); + + /// 0x00A6 — D4's panel-open/close declaration (the + /// vitals-stream gate, lane B §4.5). Idempotent at the CALLER + /// ( + /// only invokes this on an actual visibility transition); this method + /// itself just forwards, exactly like every sibling command — a call + /// while disconnected returns + /// from without sending anything. + public RuntimeCommandResult FellowshipSetPanelOpen(bool panelOpen) => + Invoke((commands, generation) => commands.Fellowship.SetPanelOpen( + generation, panelOpen)); + public void Deactivate() { lock (_gate) diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs index 8bc483cd..7cfe75c5 100644 --- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs @@ -111,7 +111,7 @@ public static class CharacterOptionsPageController /// steps 13-18, written after the code landed): /// /// - /// NOT dimmed (15 rows, real acdream-side + /// NOT dimmed (19 rows, real acdream-side /// consumer): Group B rows actually bound — /// ViewCombatTarget/AutoTarget/AutoRepeatAttack /// (combat: LiveCombatAttackOperations.cs, @@ -122,10 +122,17 @@ public static class CharacterOptionsPageController /// DisableDistanceFog/DisplayTimeStamps/ToggleRun /// (Group B, polled at GameWindow.cs:655-680), /// DragItemOnPlayerOpensSecureTrade (TS-48, - /// InteractionRetainedUiComposition.cs:326), and all six + /// InteractionRetainedUiComposition.cs:326), all six /// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136 - /// gates every Turbine room join on the matching bit). - /// Dimmed (35 rows, store-only): every + /// gates every Turbine room join on the matching bit), and — landed at + /// Campaign FA slice FA4, D7 — IgnoreFellowshipRequests/ + /// FellowshipAutoAcceptRequests (consumed by + /// RetailUiRuntime.TryAutoRespondToFellowshipInvite's auto- + /// decline/auto-accept, D6) and FellowshipShareXP/ + /// FellowshipShareLoot (consumed by the fellowship page's own + /// Create flow / D5 display / second checkbox surface, + /// SocialFellowshipPageController). + /// Dimmed (31 rows, store-only): every /// remaining Group A row (wire+store only — ACE, not acdream, is the /// consumer) and every remaining Group B row the OP4 gate script's own /// step 16 lists as "no consumer surface" (ShowTooltips, @@ -134,8 +141,10 @@ public static class CharacterOptionsPageController /// DisableMostWeatherEffects, PersistentAtDay, /// FilterLanguage, MainPackPreferred), plus every Group D /// deferral (SalvageMultiple, - /// DisableHouseRestrictionEffects, FellowshipShareXP, - /// HearPkDeathMessages). + /// DisableHouseRestrictionEffects, + /// HearPkDeathMessages) — FellowshipShareXP left Group D + /// at FA4 (above), it stays a bullet-list resident here only for the + /// historical count. /// Flagged ambiguity, resolved by code evidence /// (see final report, not re-litigated here): the research doc's /// §7.1 lists AcceptLootPermits in BOTH Group A's bullet list AND @@ -145,13 +154,14 @@ public static class CharacterOptionsPageController /// not a behavioral reader — ACE alone decides whether to honor corpse /// looting (Player_Death.cs:755). Classified Group A / dimmed. /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests - /// have a genuine coded mutual-exclusion side effect + /// had a genuine coded mutual-exclusion side effect /// (RuntimeCharacterState.TrySetOption, MF-2) that the research - /// doc's Group A classification does not capture (the doc predates - /// OP1's review-fix round) — kept dimmed per the doc's explicit Group A - /// listing and this task's instruction to treat the doc as the sole - /// authority rather than infer from code comments; a future un-dim is a - /// one-line judgment call, not a re-investigation. + /// doc's Group A classification did not capture, kept dimmed at OP1 + /// pending "a future un-dim is a one-line judgment call, not a + /// re-investigation" — that future consumer landed at Campaign FA slice + /// FA4 (see D7's addendum to register row AD-78), so both rows moved to + /// the NOT-dimmed bullet above, together with the two + /// FellowshipShare* rows FA4 also gave real consumers. /// /// private const bool Live = false; @@ -192,12 +202,12 @@ public static class CharacterOptionsPageController }), new("ID_CharacterOption_Grouping_Section", new RowSpec[] { - new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A - new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note) - new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A - new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D - new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // Group A - new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // Group A (see ambiguity note) + new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum + new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-decline (RetailUiRuntime.TryAutoRespondToFellowshipInvite) + new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum + new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", Live), // D7 (Campaign FA slice FA4): consumed by the fellowship Create flow (its value IS the sent shareXP bit) and the panel's D5 XP-share display + new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", Live), // D7 (Campaign FA slice FA4): now also a SECOND live checkbox surface on the fellowship page itself (SocialFellowshipPageController) + new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-accept (suppresses the confirmation dialog) }), new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[] { diff --git a/src/AcDream.App/UI/Layout/RowTemplateResolver.cs b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs new file mode 100644 index 00000000..17f018ae --- /dev/null +++ b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs @@ -0,0 +1,74 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Caching row-template resolver for +/// 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 0x64 template entries through +/// one instance of this class. +/// +/// +/// Originally a private local function inside +/// (Campaign FA slice FA3 +/// fix-round blast SF-2): the ORIGINAL shape re-ran +/// LayoutImporter.ImportInfos — 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 +/// tree the FIRST time it is imported and never +/// re-imports for that pair again; still calls +/// (constructor parameter) on every invocation +/// because each row needs its OWN instance — only +/// the expensive per-row DAT tree WALK is memoized, not the built widget. +/// +/// +/// +/// 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 +/// / 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 at the call site (see +/// RetailUiRuntime.MountSocialPanel) — 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. +/// +/// +public sealed class RowTemplateResolver +{ + private readonly Dictionary<(uint LayoutId, uint ElementId), ElementInfo?> _cache = new(); + private readonly Func _importInfos; + private readonly Func _build; + + /// Number of times (the constructor + /// parameter) actually ran — i.e. cache MISSES. Exposed for the + /// production-resolver conformance test; not used by any production + /// code path. + public int ImportCount { get; private set; } + + public RowTemplateResolver( + Func importInfos, + Func build) + { + _importInfos = importInfos ?? throw new ArgumentNullException(nameof(importInfos)); + _build = build ?? throw new ArgumentNullException(nameof(build)); + } + + /// 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). 's exact + /// signature. + 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); + } +} diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs index f94d6c9c..9ec9e69a 100644 --- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs @@ -1,52 +1,230 @@ using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; using AcDream.Runtime; namespace AcDream.App.UI.Layout; /// -/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap — -/// the ONLY behavior this SHELL slice owns for this page (roster rows, -/// vitals, create dialog, and the option row un-dims are FA4 scope). +/// Campaign FA slice FA4: the Fellowship page fully live — roster rows, the +/// 0x00A6 panel-open/vitals-stream gate (D4), the inline create flow, +/// member actions + their button-enable rules, the four option checkboxes, +/// and the open/close caption swap. FA3 shipped only the empty/full frame +/// swap (see that slice's own doc, still the authority for the frame-pair +/// containment finding). /// /// -/// Retail gmFellowshipUI::Update @0x0048F440 -/// (docs/research/2026-08-11-fa-panel-structure.md §4.5): -/// m_pNotInAFellowshipFrame (0x1000026B) shows and -/// m_pInAFellowshipFrame (0x10000275) hides when -/// m_pFellowship == null, and vice-versa. Both are AUTHORED sibling -/// containers of the fellowship page (0x10000292) — confirmed by the -/// FA3 live-mount probe: 0x1000026B holds the name-entry box, Create -/// button, and all FOUR visible option checkboxes (Ignore Fellowship -/// Requests / Auto-Accept Requests / Share XP / Share Loot — -/// 0x100002700x10000273, fix-round mechanism SF-4); 0x10000275 holds -/// the roster list and all six member-management buttons. A single -/// toggle on each container is therefore the -/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A -/// unknown U6). +/// Roster rebuild discipline — FA3 carry-forward 1. +/// bumps ONE monotonic revision for +/// every mutation, including a pure vitals refresh +/// (0x02C0 with the same member set — lane B §2.6/§4.5, the +/// 0x00A6-gated stream this same slice turns on). A naive +/// "revision changed → Flush + rebuild" binding (the Friends/Squelch +/// shape) would therefore tear down and rebuild the whole ListBox on every +/// incoming vitals tick while the panel is open — resetting the user's +/// scroll position to the top every time ( +/// zeroes the scroll offset) and re-resolving nine DAT row templates for no +/// structural reason. instead diffs the member +/// GUID SET: when it is unchanged, existing rows are updated IN PLACE +/// ( — no ListBox mutation at all, so scroll position +/// is untouched by construction); only an actual join/leave/disband +/// difference triggers +/// (the widget-level half of the same carry-forward, for the rarer +/// structural-change case). +/// +/// +/// +/// Row-text StringInfo gap (register row filed this slice). Every +/// row field beyond the bare name is a retail StringInfo template +/// with variable substitution (ID_Fellowship_FellowStats + vars +/// ID_Level/ID_Experience; …FellowHealthStatus/ +/// …StaminaStatus/…ManaStatus + vars ID_Cur/ID_Max +/// — docs/research/2026-08-11-fa-panel-structure.md §3.1/§4.1). acdream has +/// not ported retail's variable-substitution engine +/// (StringInfo::InqStringStringTableMetaLanguage::UnescapeString +/// — a cross-cutting UI-string prerequisite, not fellowship-specific) so +/// this controller renders the numeric CONTENT of those fields as plain +/// composites ("{level} {pct}%", "{cur}/{max}") 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"). +/// +/// +/// +/// Leader marker. 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 +/// () as a minimal, clearly-adaptive visual +/// cue rather than inventing a DAT mechanism that was not found. /// /// 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_ 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; + + /// 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. + 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]; + + /// Fellowship::IsFull @0x005B9070, byte-verified + /// _currNum >= 9 (lane B §7.1). + private const int MaxFellowshipSize = 9; + + private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f); + private static readonly Vector4 MemberNameColor = Vector4.One; + + /// The live read/write seam this page binds to — Runtime's + /// FA2 fellowship owner (snapshot + member enumeration), the seven + /// generation-gated commands routed through + /// , + /// 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 + /// CharacterOptionsPageController's Character tab uses — both + /// surfaces are live views of one value, lane A §6.5), and a DAT string + /// resolver. + public sealed record Bindings( + Func Snapshot, + Func> Members, + Func TemplateResolver, + Func Create, + Func Recruit, + Func Dismiss, + Func Quit, + Func AssignLeader, + Func SetOpen, + Func SetPanelOpen, + SelectionState Selection, + Func LocalPlayerGuid, + Func CurrentCharacterOption, + Action SetCharacterOption, + Func ResolveString); + + private readonly record struct FellowRowWidgets( + UiText? Name, + UiText? Stats, + UiMeter? Health, + UiMeter? Stamina, + UiMeter? Mana); + private readonly UiElement _notInFellowshipFrame; private readonly UiElement _inFellowshipFrame; - private readonly Func _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 _rows = new(); + private uint _selectedFellowGuid; + private long _lastRosterRevision = long.MinValue; + private bool? _lastOpenState; + private bool _pageVisible; + + /// Resolved ONCE at (not per-tick — every + /// other DatStringResolver consumer in this codebase is + /// bind-time-only; DatCollection 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. + private readonly string? _openCaption; + private readonly string? _closeCaption; private SocialFellowshipPageController( UiElement notInFellowshipFrame, UiElement inFellowshipFrame, - Func snapshot) + Bindings bindings, + UiTemplateListBox? listBox, + UiField? nameField, + UiButton? createButton, + UiText? fellowshipNameText, + UiButton? leaderButton, + UiButton? quitButton, + UiButton? openButton, + UiButton? recruitButton, + UiButton? dismissButton, + UiButton? disbandButton, + UiButton? ignoreRequestsCheckbox, + UiButton? autoAcceptCheckbox, + UiButton? shareXpCheckbox, + UiButton? shareLootCheckbox, + string? openCaption, + string? closeCaption) { _notInFellowshipFrame = notInFellowshipFrame; _inFellowshipFrame = inFellowshipFrame; - _snapshot = snapshot; + _bindings = bindings; + _listBox = listBox; + _nameField = nameField; + _createButton = createButton; + _fellowshipNameText = fellowshipNameText; + _leaderButton = leaderButton; + _quitButton = quitButton; + _openButton = openButton; + _recruitButton = recruitButton; + _dismissButton = dismissButton; + _disbandButton = disbandButton; + _ignoreRequestsCheckbox = ignoreRequestsCheckbox; + _autoAcceptCheckbox = autoAcceptCheckbox; + _shareXpCheckbox = shareXpCheckbox; + _shareLootCheckbox = shareLootCheckbox; + _openCaption = openCaption; + _closeCaption = closeCaption; } - public static SocialFellowshipPageController? Bind( - UiElement pageRoot, - Func snapshot) + public static SocialFellowshipPageController? Bind(UiElement pageRoot, Bindings bindings) { ArgumentNullException.ThrowIfNull(pageRoot); - ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(bindings); if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn || UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship) @@ -58,23 +236,446 @@ public sealed class SocialFellowshipPageController return null; } - var controller = new SocialFellowshipPageController(notIn, inFellowship, snapshot); + UiTemplateListBox? listBox = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox; + if (listBox is null) + Console.WriteLine( + $"[D.2b] SocialFellowshipPageController: ListBox 0x{ListBoxId:X8} not " + + "found — the fellowship roster will not populate."); + else + { + listBox.TemplateResolver = bindings.TemplateResolver; + uint scrollbarElementId = listBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(pageRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] SocialFellowshipPageController: scrollbar 0x{scrollbarElementId:X8} " + + "not found — the fellowship roster will not scroll."); + } + + UiField? nameField = UiElement.FindDescendant(pageRoot, NameEntryBoxId) as UiField; + if (nameField is null) + Console.WriteLine( + $"[D.2b] SocialFellowshipPageController: name-entry field 0x{NameEntryBoxId:X8} " + + "not found (or not authored Editable) — Create will not read a typed name."); + + UiButton? createButton = UiElement.FindDescendant(pageRoot, CreateButtonId) as UiButton; + UiText? fellowshipNameText = UiElement.FindDescendant(pageRoot, FellowshipNameTextId) as UiText; + UiButton? leaderButton = UiElement.FindDescendant(pageRoot, LeaderButtonId) as UiButton; + UiButton? quitButton = UiElement.FindDescendant(pageRoot, QuitButtonId) as UiButton; + UiButton? openButton = UiElement.FindDescendant(pageRoot, OpenButtonId) as UiButton; + UiButton? recruitButton = UiElement.FindDescendant(pageRoot, RecruitButtonId) as UiButton; + UiButton? dismissButton = UiElement.FindDescendant(pageRoot, DismissButtonId) as UiButton; + UiButton? disbandButton = UiElement.FindDescendant(pageRoot, DisbandButtonId) as UiButton; + UiButton? ignoreRequestsCheckbox = UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton; + UiButton? autoAcceptCheckbox = UiElement.FindDescendant(pageRoot, AutoAcceptCheckboxId) as UiButton; + UiButton? shareXpCheckbox = UiElement.FindDescendant(pageRoot, ShareXpCheckboxId) as UiButton; + UiButton? shareLootCheckbox = UiElement.FindDescendant(pageRoot, ShareLootCheckboxId) as UiButton; + + // Bind-time only (see the field's own doc) — gmFellowshipUI:: + // UpdateButtons @0x0048E6C0 (lane A §4.1): a CLOSED fellowship + // shows "Open Fellowship", the button reads as the action. + string? openCaption = bindings.ResolveString( + FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText")); + string? closeCaption = bindings.ResolveString( + FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText")); + if (openCaption is null || closeCaption is null) + Console.WriteLine( + "[D.2b] SocialFellowshipPageController: Open/Close Fellowship button caption(s) " + + "did not resolve — the button keeps its imported caption rather than " + + "invented English."); + + var controller = new SocialFellowshipPageController( + notIn, + inFellowship, + bindings, + listBox, + nameField, + createButton, + fellowshipNameText, + leaderButton, + quitButton, + openButton, + recruitButton, + dismissButton, + disbandButton, + ignoreRequestsCheckbox, + autoAcceptCheckbox, + shareXpCheckbox, + shareLootCheckbox, + openCaption, + closeCaption); + + controller.WireButtons(); + controller.WireCheckboxes(); controller.Tick(); return controller; } + private void WireButtons() + { + if (_createButton is not null) + _createButton.OnClick = () => + { + string name = _nameField?.Text ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) return; + bool shareXp = _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareXP); + _bindings.Create(name, shareXp); + }; + + if (_recruitButton is not null) + _recruitButton.OnClick = () => + { + if (_bindings.Selection.SelectedObjectId is { } targetGuid) + _bindings.Recruit(targetGuid); + }; + + if (_dismissButton is not null) + _dismissButton.OnClick = () => + { + if (_selectedFellowGuid != 0u) _bindings.Dismiss(_selectedFellowGuid); + }; + + if (_leaderButton is not null) + _leaderButton.OnClick = () => + { + if (_selectedFellowGuid != 0u) _bindings.AssignLeader(_selectedFellowGuid); + }; + + // Quit and Disband are the same 0x00A3 opcode, flag flipped (lane B + // §2.5/§3.2). The retail leader-handoff pre-send (0x0290 before + // 0x00A3 when quitting-not-disbanding as the current leader) lives + // in RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit, applied + // by the command adapters — Quit(disband) here is the whole story. + if (_quitButton is not null) + _quitButton.OnClick = () => _bindings.Quit(false); + if (_disbandButton is not null) + _disbandButton.OnClick = () => _bindings.Quit(true); + + if (_openButton is not null) + _openButton.OnClick = () => + { + RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot(); + _bindings.SetOpen(!snapshot.IsOpen); + }; + } + + private void WireCheckboxes() + { + BindCheckbox(_ignoreRequestsCheckbox, CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests"); + BindCheckbox(_autoAcceptCheckbox, CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests"); + BindCheckbox(_shareXpCheckbox, CharacterOptionId.FellowshipShareXP, "FellowshipShareXP"); + BindCheckbox(_shareLootCheckbox, CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot"); + } + + private void BindCheckbox(UiButton? checkbox, CharacterOptionId id, string retailName) + { + if (checkbox is null) return; + + string labelKey = $"ID_PlayerOption_{retailName}"; + string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey)); + if (label is not null) + checkbox.Label = label; + else + Console.WriteLine( + $"[D.2b] SocialFellowshipPageController: label '{labelKey}' did not resolve — " + + "checkbox renders with no caption rather than invented English."); + + string? tooltip = _bindings.ResolveString( + OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); + if (tooltip is not null) + checkbox.TooltipText = tooltip; + + checkbox.OnClick = () => + { + bool next = !checkbox.Selected; + checkbox.Selected = next; + _bindings.SetCharacterOption(id, next); + }; + } + /// - /// Re-reads the live snapshot and applies retail's frame swap. Cheap (two - /// bool writes) — called every frame from - /// rather than gated on - /// page-shown/revision, since has no - /// data-driven provider mechanism of its own (unlike , - /// which is polled by the render loop already). + /// Re-reads live state and applies retail's frame swap, roster diff, + /// button-enable rules, and checkbox seeding. Called every frame from + /// , matching every other + /// retained panel's empty-state gate (see FA3's own doc for why this + /// stays unconditional rather than visibility-gated: two bool writes + /// plus, at most, a revision-gated roster diff — no DAT access). /// public void Tick() { - bool inFellowship = _snapshot().IsInFellowship; + RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot(); + bool inFellowship = snapshot.IsInFellowship; _notInFellowshipFrame.Visible = !inFellowship; _inFellowshipFrame.Visible = inFellowship; + + RefreshCheckboxSelections(); + RefreshCreateButtonState(); + + if (!inFellowship) + { + if (_rows.Count != 0) + { + _listBox?.Flush(); + _rows.Clear(); + } + _selectedFellowGuid = 0u; + _lastRosterRevision = long.MinValue; + _lastOpenState = null; + return; + } + + RefreshFellowshipName(snapshot.Name); + + if (snapshot.Revision != _lastRosterRevision) + { + _lastRosterRevision = snapshot.Revision; + RefreshRoster(snapshot); + } + + RefreshButtonStates(snapshot); + RefreshOpenCaption(snapshot); + } + + /// + /// D4 — the panel-open declaration (0x00A6) 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 + /// 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 + /// without sending). + /// + 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); + } + } + + /// + /// 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 + /// (carry-forward 1). + /// + private void RefreshRoster(RuntimeFellowshipSnapshot snapshot) + { + if (_listBox is null) return; + + var members = new List(_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 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); + } + + /// + /// D5 — gmFellowshipUI::UpdateFellowStats @0x0048EB20 (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: + /// when even-split, 0% when + /// is off (retail's own + /// literal pct = 0.0f 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. + /// + 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}"; + } + + /// + /// Retail list-selection message (3/0x42, + /// gmFellowshipUI::ListenToElementMessage @0x004901C0, lane B + /// §2.8): reads the clicked row's identity into + /// m_iidSelectedFellow AND calls + /// ACCWeenieObject::SetSelectedObject — 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. + /// + private void SelectFellow(uint guid) + { + _selectedFellowGuid = guid; + _bindings.Selection.Select(guid, SelectionChangeSource.Social); } } diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs index 4226d310..0ec734bb 100644 --- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs @@ -36,15 +36,20 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Scrollbar (fix-round blast MF-1). The ListBox 0x10000517 -/// authors a direct sibling scrollbar, 0x10000518 — wired to -/// the SAME way every other -/// UiTemplateListBox consumer wires its own scrollbar -/// (CharacterOptionsPageController/ChatOptionsPageController/ -/// ConfigOptionsPageController/KeyboardConfigController). -/// Without it the list has NO wheel fallback ( -/// has no wheel handler) and is completely unreachable past the box's -/// visible extent. +/// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The +/// ListBox 0x10000517 authors a direct sibling scrollbar, +/// 0x10000518 — wired to the +/// SAME way every other UiTemplateListBox consumer wires its own +/// scrollbar. Resolved via the built +/// (dat property 0x72, populated by ) +/// rather than the hardcoded literal a prior revision of this file used — +/// same "prefer the DAT field over a duplicated constant" fix +/// ConfigOptionsPageController.Bind already applied (OP6 review N2, +/// feedback_prefer_dat_field_over_geometry), 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 +/// ( has no wheel handler) and is completely +/// unreachable past the box's visible extent. /// /// /// @@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController { private const uint ListBoxId = 0x10000517u; - /// The ListBox's own linked scrollbar (see class doc). - private const uint ScrollbarElementId = 0x10000518u; - private readonly UiTemplateListBox _listBox; private readonly FriendsState _friends; private long _lastRevision = long.MinValue; @@ -95,11 +97,15 @@ public sealed class SocialFriendsPageController } listBox.TemplateResolver = templateResolver; - if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar) + uint scrollbarElementId = listBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(pageRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) scrollbar.Model = listBox.Scroll; else Console.WriteLine( - $"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} " + $"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} " + "not found — the Friends list will not scroll."); var controller = new SocialFriendsPageController(listBox, friends); diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index b0e8ad32..59538a3e 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -86,10 +86,15 @@ public sealed class SocialPanelController : IRetainedPanelController private const uint CloseButtonId = 0x10000290u; /// Callback delegates + live-state accessors this controller - /// wires the four pages and the close button to. + /// wires the four pages and the close button to. Campaign FA slice FA4: + /// replaces the old bare + /// FellowshipSnapshot field with the page's full read/write seam + /// (roster enumeration, the seven commands, selection, character + /// options, string resolution — see + /// 's own doc). public sealed record Callbacks( Action Toggle, - Func FellowshipSnapshot, + SocialFellowshipPageController.Bindings Fellowship, Func AllegianceSnapshot, FriendsState Friends, SquelchState Squelch, @@ -122,6 +127,17 @@ public sealed class SocialPanelController : IRetainedPanelController _allegiance = allegiance; _friends = friends; _squelch = squelch; + + // Campaign FA slice FA4, D4: the Fellowship page's 0x00A6 + // panel-open declaration needs both "is the social WINDOW shown" + // (OnShown/OnHidden, below) AND "is Fellowship the ACTIVE tab" + // (this event) — retail's gmFellowshipUI::OnVisibilityChanged fires + // on the WIDGET's own visibility, and in this campaign's one-window/ + // four-page mount that's the conjunction of both. Subscribed here + // (not in Bind) so it observes every tab switch, including the + // very first one ActivateTabBehavior's default-entry activation + // fires. + _tabPanel.ActivePageChanged += (_, _) => UpdateFellowshipPageVisibility(); } /// @@ -156,7 +172,7 @@ public sealed class SocialPanelController : IRetainedPanelController SocialFellowshipPageController? fellowship = fellowshipPage is null ? null - : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot); + : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship); SocialAllegiancePageController? allegiance = allegiancePage is null ? null : SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot); @@ -229,12 +245,27 @@ public sealed class SocialPanelController : IRetainedPanelController /// window manager on every hidden-to-shown transition (fix-round blast /// SF-2). Does not force an immediate Friends/Squelch rebuild — the /// next naturally picks up any revision bump that - /// accumulated while hidden. - public void OnShown() => _visible = true; + /// accumulated while hidden. Also drives D4 (see the constructor's own + /// doc on ). + public void OnShown() + { + _visible = true; + UpdateFellowshipPageVisibility(); + } /// hook, fired on every - /// shown-to-hidden transition (fix-round blast SF-2). - public void OnHidden() => _visible = false; + /// shown-to-hidden transition (fix-round blast SF-2). Also drives D4. + public void OnHidden() + { + _visible = false; + UpdateFellowshipPageVisibility(); + } + + /// 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. + private void UpdateFellowshipPageVisibility() => + _fellowship?.SetPageVisible(_visible && IsShowingFellowship); /// /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no @@ -247,11 +278,16 @@ public sealed class SocialPanelController : IRetainedPanelController /// Fix-round blast SF-2. The Friends/Squelch rebuild /// (/ /// ) takes the shared DAT - /// lock per row template resolve — it only runs while - /// is true. Fellowship/Allegiance stay unconditional: their own - /// Tick() methods are two bool/delegate writes with no DAT - /// access, cheap enough to keep polling every frame the way every other - /// retained panel's empty-state gate already does. + /// lock per row template resolve on EVERY revision change — it only + /// runs while is true. Fellowship/Allegiance stay + /// unconditional: Allegiance's own Tick() is two bool/delegate + /// writes with no DAT access; Fellowship's (FA4) is revision-GATED + /// internally and, within that gate, further diffs the member set + /// before ever touching the ListBox — a DAT-locked row rebuild only + /// runs on an actual join/leave/disband, an inherently rare event + /// (unlike Friends/Squelch's "any revision → full rebuild" shape), so + /// polling unconditionally stays cheap in the common case (a snapshot + /// read + a `long` comparison) even while the panel is hidden. /// /// public void Tick() diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs index 6a010521..5f47f73a 100644 --- a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs @@ -31,12 +31,14 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Scrollbar (fix-round blast MF-1). The ListBox 0x1000053E -/// authors a direct sibling scrollbar, 0x10000543 — wired to +/// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The +/// ListBox 0x1000053E authors a direct sibling scrollbar, +/// 0x10000543 — resolved via the built +/// and wired to /// the same way /// wires its own (see that /// class's doc for the full rationale, including why there is no wheel -/// fallback). +/// fallback and why the authored field replaces a hardcoded literal). /// /// /// @@ -52,9 +54,6 @@ public sealed class SocialSquelchPageController { private const uint ListBoxId = 0x1000053Eu; - /// The ListBox's own linked scrollbar (see class doc). - private const uint ScrollbarElementId = 0x10000543u; - private readonly UiTemplateListBox _listBox; private readonly SquelchState _squelch; private long _lastRevision = long.MinValue; @@ -83,11 +82,15 @@ public sealed class SocialSquelchPageController } listBox.TemplateResolver = templateResolver; - if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar) + uint scrollbarElementId = listBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(pageRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) scrollbar.Model = listBox.Scroll; else Console.WriteLine( - $"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} " + $"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} " + "not found — the Squelch list will not scroll."); var controller = new SocialSquelchPageController(listBox, squelch); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index cd7c005c..5496a785 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -14,6 +14,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Selection; using AcDream.Core.Spells; +using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Content; using AcDream.Core.Input; @@ -225,12 +226,42 @@ public sealed record OptionsRuntimeBindings( /// the panel's read-only rows need the actual entries, same access shape /// /// already exposes for every other Communication-owned consumer). +/// +/// +/// Campaign FA slice FA4 adds the Fellowship page's write surface: +/// (the roster +/// alone cannot enumerate — see that interface's own FA4 addendum) and the +/// seven generation-gated commands, all routed through +/// +/// (the SAME late-bound, race-safe seam AddShortcut/Advance +/// already use) rather than a raw WorldSession.SendXxx call — +/// FellowshipQuit in particular MUST go through +/// IRuntimeFellowshipCommands.Quit because the retail leader +/// hand-off rule (0x0290 before 0x00A3) lives in +/// RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit + +/// DirectGameRuntimeCommandAdapter.Quit/ +/// CurrentGameRuntimeCommandAdapter.Quit, not in +/// WorldSession itself — bypassing the command layer would silently +/// drop that behavior. is the Recruit target source +/// (retail's ACCWeenieObject::selectedID — lane B §2.3, the +/// currently-selected WORLD object, not a panel-local selection). +/// /// public sealed record SocialRuntimeBindings( Func FellowshipSnapshot, Func AllegianceSnapshot, AcDream.Core.Social.FriendsState Friends, - AcDream.Core.Social.SquelchState Squelch); + AcDream.Core.Social.SquelchState Squelch, + Func> FellowshipMembers, + Func FellowshipCreate, + Func FellowshipRecruit, + Func FellowshipDismiss, + Func FellowshipQuit, + Func FellowshipAssignLeader, + Func FellowshipSetOpen, + Func FellowshipSetPanelOpen, + SelectionState Selection, + Func LocalPlayerGuid); public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, @@ -631,8 +662,35 @@ public sealed class RetailUiRuntime : IDisposable } } + /// + /// Campaign FA slice FA4, D6: a fellowship invite (ConfirmationType. + /// Fellowship, 4) is intercepted BEFORE it ever reaches the generic + /// dialog — retail's + /// Fellowship.cs:121-equivalent client-side mirror: + /// IgnoreFellowshipRequests auto-declines, FellowshipAutoAcceptRequests + /// auto-accepts, and the two are mutually exclusive + /// (RuntimeCharacterState.TrySetOption, 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). + /// public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request) - => _gameplayConfirmationController?.HandleRequest(request) == true; + { + if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship + && TryAutoRespondToFellowshipInvite(request)) + return true; + return _gameplayConfirmationController?.HandleRequest(request) == true; + } + + private bool TryAutoRespondToFellowshipInvite(GameEvents.CharacterConfirmationRequest request) + { + bool ignore = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.IgnoreFellowshipRequests); + bool autoAccept = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.FellowshipAutoAcceptRequests); + if (!ignore && !autoAccept) return false; + + _bindings.Confirmations.SendResponse(request.Type, request.ContextId, autoAccept); + return true; + } public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done) => _gameplayConfirmationController?.HandleDone(done) == true; @@ -2641,20 +2699,19 @@ public sealed class RetailUiRuntime : IDisposable // resolvers read the shared, non-thread-safe DatCollection) because // each row needs its OWN UiElement instance — but the expensive // per-row DAT tree walk is gone after the first resolve. - var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>(); - UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId) - { - lock (_bindings.Assets.DatLock) + // FA4 carry-forward 2: the caching logic itself now lives in the + // hermetically-testable Layout.RowTemplateResolver (see its own + // class doc) — this closure supplies only the two DAT-touching + // primitives (import, build) and keeps the shared lock at the + // production call site. Feeds Friends/Squelch's row families (FA3) + // AND Fellowship's (FA4, 0x21000030/0x10000281) through the SAME + // instance/cache — all three are UiTemplateListBox row families on + // the one social panel. + var rowTemplates = new Layout.RowTemplateResolver( + (layoutId, elementId) => LayoutImporter.ImportInfos( + _bindings.Assets.Dats, layoutId, elementId), + info => { - var key = (templateLayoutId, templateElementId); - if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info)) - { - info = LayoutImporter.ImportInfos( - _bindings.Assets.Dats, templateLayoutId, templateElementId); - rowTemplateCache[key] = info; - } - if (info is null) return null; - var strings = new DatStringResolver(_bindings.Assets.Dats); return LayoutImporter.Build( info, @@ -2662,19 +2719,53 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont, strings.Resolve).Root; - } + }); + UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + return rowTemplates.Resolve(templateLayoutId, templateElementId); } + // Shared across every bind-time ResolveString call below (2 + // captions + 4x2 checkbox label/tooltip) so its internal + // per-table-id cache actually helps rather than re-reading the same + // StringTable object ten times. + var fellowshipStrings = new DatStringResolver(_bindings.Assets.Dats); + var callbacks = new Layout.SocialPanelController.Callbacks( Toggle: () => ToggleWindow(WindowNames.SocialPanel), - FellowshipSnapshot: _bindings.Social.FellowshipSnapshot, + Fellowship: new Layout.SocialFellowshipPageController.Bindings( + Snapshot: _bindings.Social.FellowshipSnapshot, + Members: _bindings.Social.FellowshipMembers, + TemplateResolver: TemplateResolver, + Create: _bindings.Social.FellowshipCreate, + Recruit: _bindings.Social.FellowshipRecruit, + Dismiss: _bindings.Social.FellowshipDismiss, + Quit: _bindings.Social.FellowshipQuit, + AssignLeader: _bindings.Social.FellowshipAssignLeader, + SetOpen: _bindings.Social.FellowshipSetOpen, + SetPanelOpen: _bindings.Social.FellowshipSetPanelOpen, + Selection: _bindings.Social.Selection, + LocalPlayerGuid: _bindings.Social.LocalPlayerGuid, + CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id), + SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish( + new SetSingleCharacterOptionRuntimeCmd((uint)id, value)), + ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)), AllegianceSnapshot: _bindings.Social.AllegianceSnapshot, Friends: _bindings.Social.Friends, Squelch: _bindings.Social.Squelch, TemplateResolver: TemplateResolver); - Layout.SocialPanelController? controller = - Layout.SocialPanelController.Bind(layout, callbacks); + // A second DatLock scope (MountOptionsPanel's own precedent, above): + // SocialFellowshipPageController.Bind resolves the Open/Close + // caption pair and all four checkbox label/tooltip strings at BIND + // TIME (never per-tick — see that class's own doc on why), and + // Friends/Squelch/Fellowship's row-building all reach the SAME + // DatLock through TemplateResolver above (re-entrant on this + // thread — Monitor.Enter is safe to re-acquire recursively). + Layout.SocialPanelController? controller; + lock (_bindings.Assets.DatLock) + controller = Layout.SocialPanelController.Bind(layout, callbacks); if (controller is null) { Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel."); diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs index 1560f404..f4dfaecc 100644 --- a/src/AcDream.App/UI/UiTemplateListBox.cs +++ b/src/AcDream.App/UI/UiTemplateListBox.cs @@ -266,4 +266,35 @@ public sealed class UiTemplateListBox : UiDatElement /// /// public void Flush() => _viewport?.ClearContent(); + + /// + /// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like + /// , but preserves the CURRENT scroll offset instead + /// of resetting it to 0. 's ClearContent zeroes + /// '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 0x02C0 would fight the user's own + /// scrolling the instant they touch the fellowship list. + /// 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; + /// 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 + /// already relies on for every ordinary + /// call. A no-op (same as ) while dormant. + /// + public void FlushPreservingScroll() + { + if (_viewport is null) return; + int savedScrollY = _viewport.Scroll.ScrollY; + _viewport.ClearContent(); + _viewport.Scroll.SetScrollY(savedScrollY); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs index 066fdb3e..aa9bd75a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs @@ -698,14 +698,19 @@ public sealed class CharacterOptionsPageControllerTests // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── /// - /// The exact 35 values this slice dims + /// The exact 31 values this slice dims /// ( == true), transcribed independently /// of CharacterOptionsPageController.Groups from the derivation /// table in that class's own doc comment. Wiring a future consumer /// for any of these means removing it from this literal set AND /// flipping its Groups table entry from StoreOnly to /// Live consciously — leaving either one stale fails this - /// test. + /// test. Campaign FA slice FA4, D7 removed four ids from this set — + /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests + /// (the fellowship-invite auto-decline/auto-accept, D6) and + /// FellowshipShareXP/FellowshipShareLoot (the fellowship + /// page's Create flow / D5 display / second checkbox surface) all + /// gained real acdream-side consumers. /// private static readonly HashSet ExpectedStoreOnlyIds = [ @@ -724,13 +729,9 @@ public sealed class CharacterOptionsPageControllerTests CharacterOptionId.FilterLanguage, CharacterOptionId.ShowHelm, CharacterOptionId.ShowCloak, - // Group 3 (Grouping) — all 6 + // Group 3 (Grouping) — 2 of 6 (FA4, D7: the other four — see class doc) CharacterOptionId.IgnoreAllegianceRequests, - CharacterOptionId.IgnoreFellowshipRequests, CharacterOptionId.DisplayAllegianceLogonNotifications, - CharacterOptionId.FellowshipShareXP, - CharacterOptionId.FellowshipShareLoot, - CharacterOptionId.FellowshipAutoAcceptRequests, // Group 4 (Other Players) — 10 of 11 CharacterOptionId.AcceptLootPermits, CharacterOptionId.UseDeception, @@ -761,8 +762,8 @@ public sealed class CharacterOptionsPageControllerTests .ToHashSet(); Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly); - Assert.Equal(35, actualStoreOnly.Count); - Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows + Assert.Equal(31, actualStoreOnly.Count); + Assert.Equal(19, 50 - actualStoreOnly.Count); // the 19 live rows (FA4, D7: +4) } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs b/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs new file mode 100644 index 00000000..2e973068 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs @@ -0,0 +1,91 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 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"). +/// is the extracted, hermetically-testable +/// caching resolver RetailUiRuntime.MountSocialPanel 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. +/// +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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs new file mode 100644 index 00000000..8c2d196d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs @@ -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; + +/// +/// Campaign FA slice FA4: hermetic (no DAT, no live runtime) tests for +/// 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). +/// +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 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; + } + + /// 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 ). + 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 Members = []; + public readonly List Calls = []; + public readonly Dictionary Options = new(); + public SelectionState Selection = new(); + public uint LocalPlayerGuid; + public Func 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(UiElement.FindDescendant(row, RowNameTextId)); + Assert.Equal("Alice", name.LinesProvider().Single().Text); + + UiText stats = Assert.IsType(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(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(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(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(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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index ff7391c0..ff0db6a8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -1,6 +1,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; using AcDream.Core.Social; using AcDream.Runtime; @@ -16,6 +17,45 @@ namespace AcDream.App.Tests.UI.Layout; /// public sealed class SocialPanelControllerTests { + private static readonly RuntimeCommandResult InactiveResult = + new(RuntimeCommandStatus.Inactive, default); + + /// Campaign FA slice FA4: the Fellowship page's full read/write + /// seam. Every command records its call (by name) into + /// and returns — + /// hermetic tests here exercise WIRING (does clicking X call the right + /// delegate with the right argument), not live command semantics + /// (covered by RuntimeFellowshipStateTests/ + /// DirectGameRuntimeCommandAdapterTests). + private static SocialFellowshipPageController.Bindings MakeFellowshipBindings( + List? calls = null, + RuntimeFellowshipSnapshot snapshot = default, + IEnumerable? members = null, + SelectionState? selection = null, + uint localPlayerGuid = 0u, + Func? currentCharacterOption = null, + Func? templateResolver = null, + Func? resolveString = null) + { + calls ??= new List(); + 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( List? calls = null, RuntimeFellowshipSnapshot fellowship = default, @@ -26,7 +66,7 @@ public sealed class SocialPanelControllerTests calls ??= new List(); return new SocialPanelController.Callbacks( Toggle: () => calls.Add("toggle"), - FellowshipSnapshot: () => fellowship, + Fellowship: MakeFellowshipBindings(calls, fellowship), AllegianceSnapshot: () => allegiance, Friends: friends ?? new FriendsState(), Squelch: squelch ?? new SquelchState(), diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs index d4fecac9..bf6d50c4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs @@ -1,6 +1,7 @@ using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Runtime; using DatReaderWriter; using DatReaderWriter.Options; @@ -109,6 +110,137 @@ public sealed class SocialPanelLiveMountProbeTests 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(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(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(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. foreach ((uint id, string name) in new[] { diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs new file mode 100644 index 00000000..0311d6d1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs @@ -0,0 +1,99 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI; + +/// +/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): unlike +/// , +/// 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. +/// +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); + } +}