diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs index 9ec9e69a..825fdc39 100644 --- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs @@ -61,7 +61,26 @@ namespace AcDream.App.UI.Layout; /// 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. +/// cue rather than inventing a DAT mechanism that was not found — register +/// row AD-82. +/// +/// +/// +/// World→panel selection (fix-round MUST-FIX 4). +/// gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 is retail's +/// REVERSE arm of 's panel-to-world direction: +/// selecting a fellow in the 3D world (or by any other selection origin) +/// selects their panel row too, enabling Dismiss/Assign-Leader. Retail +/// keys row identity via a generic ListBox primitive +/// (SetAttribute_InstanceID(row, 0x1000000D, fellowIid) + +/// UIElement_ListBox::SetSelectedItem) that acdream's +/// does not port (register row AD-82 — +/// "no Flush, no selection model, no per-row instance-id", +/// docs/research/2026-08-11-fa-panel-structure.md §6.6). +/// reproduces the OBSERVABLE behavior — button-enable + a visual row +/// highlight — against this controller's OWN guid-keyed row dictionary +/// () via instead of +/// a generic ListBox selection model; see AD-82 for what's deferred. /// /// public sealed class SocialFellowshipPageController @@ -110,6 +129,23 @@ public sealed class SocialFellowshipPageController private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f); private static readonly Vector4 MemberNameColor = Vector4.One; + /// Fix-round MUST-FIX 4 — the panel-local "this row is selected" + /// visual cue (see the class doc's world→panel selection note). + /// Same disposition as : no decompiled + /// anchor for a per-row selection sprite/marker was found, so this is a + /// minimal, clearly-adaptive tint rather than an invented DAT mechanism + /// — register row AD-82. Takes priority over + /// when a row is both the leader and the current selection. + private static readonly Vector4 SelectedNameColor = new(0.45f, 0.85f, 1f, 1f); + + /// Cached per — only + /// reassigned when the fellowship name actually changes (fix-round SF-1, + /// the same zero-allocation-while-idle discipline + /// SocialAllegiancePageController's BlankLineProvider/ + /// NoLinesProvider statics established for the sibling page). + private string? _lastFellowshipName; + private Func>? _fellowshipNameLinesProvider; + /// The live read/write seam this page binds to — Runtime's /// FA2 fellowship owner (snapshot + member enumeration), the seven /// generation-gated commands routed through @@ -164,6 +200,22 @@ public sealed class SocialFellowshipPageController private readonly UiButton? _shareLootCheckbox; private readonly Dictionary _rows = new(); + + /// Fix-round SF-2/SF-3: the TRUE member-guid set as of the last + /// rebuild attempt, independent of which rows actually finished + /// building. alone under-counts when a row template + /// permanently fails to build ('s own + /// continue) — comparing against .Count would + /// make 's membership-changed check true + /// FOREVER for that guid (every vitals tick re-attempts a full + /// DAT-locked + /// rebuild instead of only on a real join/leave/disband), and would let + /// Recruit's "already a fellow" check + /// () light up for someone who IS + /// already a fellow but whose row failed to build. This set is the + /// single source of truth for both. + private readonly HashSet _memberGuids = new(); + private uint _selectedFellowGuid; private long _lastRosterRevision = long.MinValue; private bool? _lastOpenState; @@ -359,7 +411,21 @@ public sealed class SocialFellowshipPageController _openButton.OnClick = () => { RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot(); - _bindings.SetOpen(!snapshot.IsOpen); + bool newOpenState = !snapshot.IsOpen; + _bindings.SetOpen(newOpenState); + + // N-0 (fix-round NIT): lane B feature 11 — retail's Open + // button handler pre-toggles its own _open_fellow BEFORE + // Event_ChangeFellowOpeness, so the caption flips + // immediately rather than waiting for the 0x02BE echo. + // RefreshOpenCaption's own _lastOpenState latch means this + // optimistic set is a no-op (already matches) once the real + // snapshot catches up, and is silently corrected on the + // next Tick if the server ever disagrees. + _lastOpenState = newOpenState; + string? label = newOpenState ? _closeCaption : _openCaption; + if (label is not null) + _openButton.Label = label; }; } @@ -407,6 +473,11 @@ public sealed class SocialFellowshipPageController /// public void Tick() { + // N-3 (fix-round NIT): Snapshot() and Members() below each take + // RuntimeFellowshipState._gate separately, so Revision/MemberCount + // can momentarily disagree with the roster RefreshRoster reads a + // few lines later. Self-correcting on the next revision bump + // (single-threaded tick today) — not assumed atomic. RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot(); bool inFellowship = snapshot.IsInFellowship; _notInFellowshipFrame.Visible = !inFellowship; @@ -422,9 +493,12 @@ public sealed class SocialFellowshipPageController _listBox?.Flush(); _rows.Clear(); } + _memberGuids.Clear(); _selectedFellowGuid = 0u; _lastRosterRevision = long.MinValue; _lastOpenState = null; + _lastFellowshipName = null; + _fellowshipNameLinesProvider = null; return; } @@ -436,6 +510,12 @@ public sealed class SocialFellowshipPageController RefreshRoster(snapshot); } + // MUST-FIX 4 (fix round): retail's UpdateFellowSelection runs from + // Update EVERY frame (not just on a selection-changed notice) — + // this call site mirrors that placement. See the class doc's + // world->panel selection note. + SyncSelectionFromWorld(); + RefreshButtonStates(snapshot); RefreshOpenCaption(snapshot); } @@ -457,6 +537,18 @@ public sealed class SocialFellowshipPageController _bindings.SetPanelOpen(visible); } + /// + /// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT + /// sending anything, so the next call + /// re-declares 0x00A6 even if the caller's own visibility + /// hasn't changed from ITS perspective. Called by + /// on every + /// generation reset — the page itself is process-lifetime and survives + /// a reconnect, but the fresh session's server has no memory of the + /// previous session's 0x00A6 declaration. + /// + public void ResetPageVisibleLatch() => _pageVisible = false; + private void RefreshCreateButtonState() { if (_createButton is null) return; @@ -486,10 +578,20 @@ public sealed class SocialFellowshipPageController _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareLoot); } + /// Fix-round SF-1: reassigns + /// (a display-class + delegate allocation) only when the name actually + /// changed, instead of on every — this method used to + /// allocate once per frame even while the panel is hidden, exactly the + /// bug 35c40a9b fixed on the sibling + /// SocialAllegiancePageController. private void RefreshFellowshipName(string name) { if (_fellowshipNameText is null) return; - _fellowshipNameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)]; + if (_fellowshipNameLinesProvider is not null && _lastFellowshipName == name) return; + + _lastFellowshipName = name; + _fellowshipNameLinesProvider = () => [new UiText.Line(name, MemberNameColor)]; + _fellowshipNameText.LinesProvider = _fellowshipNameLinesProvider; } private void RefreshOpenCaption(RuntimeFellowshipSnapshot snapshot) @@ -524,7 +626,12 @@ public sealed class SocialFellowshipPageController if (_recruitButton is not null) { uint? targetGuid = _bindings.Selection.SelectedObjectId; - bool targetValid = targetGuid is { } id && id != selfGuid && !_rows.ContainsKey(id); + // Fix-round SF-3: reads TRUE membership (_memberGuids), not + // which rows happen to have finished building (_rows) — a row + // that failed to build for a permanently-unresolvable template + // must not let Recruit re-invite someone who IS already a + // fellow. + bool targetValid = targetGuid is { } id && id != selfGuid && !_memberGuids.Contains(id); bool notFull = snapshot.MemberCount < MaxFellowshipSize; // Retail additionally requires the target to be a player // (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap @@ -537,10 +644,12 @@ public sealed class SocialFellowshipPageController } /// - /// 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). + /// Diffs the live member GUID set ( — fix-round + /// SF-2, NOT .Count, see that field's own doc) against + /// the current roster. Unchanged set → update every row's bound widgets + /// in place (no ListBox mutation, scroll untouched). Changed set → full + /// rebuild via + /// (carry-forward 1). /// private void RefreshRoster(RuntimeFellowshipSnapshot snapshot) { @@ -548,12 +657,12 @@ public sealed class SocialFellowshipPageController var members = new List(_bindings.Members()); - bool membershipChanged = members.Count != _rows.Count; + bool membershipChanged = members.Count != _memberGuids.Count; if (!membershipChanged) { foreach (RuntimeFellowMemberSnapshot member in members) { - if (_rows.ContainsKey(member.Guid)) continue; + if (_memberGuids.Contains(member.Guid)) continue; membershipChanged = true; break; } @@ -565,7 +674,7 @@ public sealed class SocialFellowshipPageController foreach (RuntimeFellowMemberSnapshot member in members) UpdateRow(member, snapshot); - if (_selectedFellowGuid != 0u && !_rows.ContainsKey(_selectedFellowGuid)) + if (_selectedFellowGuid != 0u && !_memberGuids.Contains(_selectedFellowGuid)) _selectedFellowGuid = 0u; } @@ -574,6 +683,14 @@ public sealed class SocialFellowshipPageController _listBox!.FlushPreservingScroll(); _rows.Clear(); + // Fix-round SF-2: record the FULL attempted member set up front, + // regardless of which rows below actually finish building — a + // permanently-unbuildable template is attempted once per real + // membership change, not once per vitals tick forever. + _memberGuids.Clear(); + foreach (RuntimeFellowMemberSnapshot member in members) + _memberGuids.Add(member.Guid); + foreach (RuntimeFellowMemberSnapshot member in members) { UiElement? row = _listBox.AddItemFromTemplateList(0); @@ -611,7 +728,12 @@ public sealed class SocialFellowshipPageController if (widgets.Name is { } nameText) { string name = member.Name; - Vector4 color = snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor; + // MUST-FIX 4 (fix round): selection takes priority over the + // leader tint when a row is both — it's the more immediate, + // user-driven state (see SelectedNameColor's own doc). + Vector4 color = _selectedFellowGuid == member.Guid + ? SelectedNameColor + : snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor; nameText.LinesProvider = () => [new UiText.Line(name, color)]; } @@ -639,6 +761,24 @@ public sealed class SocialFellowshipPageController /// 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. + /// + /// + /// MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round. + /// gmFellowshipUI::UpdateFellowStats @0x0048ECC9 forms + /// pct * 100.0f on the x87 stack (exact — both operands are + /// floats) then calls _ftol2 @0x005DE394 (MSVC's helper that + /// turns the FPU's round-to-nearest fistp into C's + /// truncate-toward-zero), never MathF.Round. This matters ONLY + /// at 6 and 8 fellows: the stored constants are + /// 0.44999998807907104 and 0.3499999940395355 (byte-read + /// from the PDB-paired binary), so retail's own products are + /// 44.999998… / 34.999999… and truncate to 44 / + /// 34 — a naive (int)MathF.Round(pct * 100f) (or even + /// (int)(pct * 100f), since 0.45f * 100f already rounds UP + /// to exactly 45.0f in SINGLE precision) yields the wrong 45/35. + /// Forming the product in double before truncating reproduces + /// retail's wider-than-single-precision x87 product exactly. + /// /// private static string FormatStatsText(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot) { @@ -647,7 +787,7 @@ public sealed class SocialFellowshipPageController if (snapshot.EvenXpSplit) { float pct = EvenSplitPercent(snapshot.MemberCount); - return $"{member.Level} {(int)MathF.Round(pct * 100f)}%"; + return $"{member.Level} {(int)((double)pct * 100.0)}%"; } return member.Level.ToString(); } @@ -655,6 +795,21 @@ public sealed class SocialFellowshipPageController private static float EvenSplitPercent(int memberCount) => memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f; + /// + /// N-1 (fix-round NIT): retail's UpdateFellowVitals @0x0048ED60 + /// sets meter attribute 0x69 to cur/max AND writes the + /// cur/max ints into the meter's authored CHILD text elements + /// (0x10000286 under health, 0x10000288 under stamina) — + /// acdream instead uses / + /// because + /// is true (a pre-existing widget decision, not this slice's). Noted + /// here so a future reader does not go hunting for the unbound + /// 0x10000286/0x10000288/0x1000028A children. + /// N-2: the max > 0 guard below is an acdream-side engineering + /// call (retail divides unconditionally) — the right one, since a + /// pre-population zero-max member should not render a divide-by-zero + /// fill, but not itself a "verbatim port" claim. + /// private static void SetVitals(UiMeter? meter, uint current, uint max) { if (meter is null) return; @@ -671,11 +826,49 @@ public sealed class SocialFellowshipPageController /// 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. + /// the click target (register row AD-82 — retail selects on the whole + /// row, not just its name text). /// private void SelectFellow(uint guid) { - _selectedFellowGuid = guid; + SetSelectedFellow(guid); _bindings.Selection.Select(guid, SelectionChangeSource.Social); } + + /// + /// MUST-FIX 4 (fix round) — the world→panel arm of retail's + /// two-directional selection coupling (see the class doc). Retail's + /// UpdateFellowSelection loop has two arms: a world selection + /// that matches a fellow row becomes the panel selection (this + /// method's job — the "found" arm); otherwise the CURRENT panel + /// selection is kept as a fallback as long as that fellow is still on + /// the roster (retail's "remember row as the fallback" arm — no code + /// needed here, since this method simply does nothing when the world + /// selection doesn't match a member, leaving + /// untouched), and already clears the + /// selection when its fellow leaves the roster (retail's + /// fallback-is-null case). + /// + private void SyncSelectionFromWorld() + { + if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id)) + SetSelectedFellow(id); + } + + /// Sets the panel's own selection (idempotent) and immediately + /// refreshes every row's visual selection cue — selection changes are + /// rare (a click, or a world-selection change), so re-running + /// for the bounded (≤9) roster on a change + /// is cheap and keeps the highlight in sync without waiting for the + /// next vitals-driven revision bump. + private void SetSelectedFellow(uint guid) + { + if (_selectedFellowGuid == guid) return; + _selectedFellowGuid = guid; + + if (_rows.Count == 0) return; + RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot(); + foreach (RuntimeFellowMemberSnapshot member in _bindings.Members()) + UpdateRow(member, snapshot); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs index 8c2d196d..45a4c100 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs @@ -126,6 +126,7 @@ public sealed class SocialFellowshipPageControllerTests public SelectionState Selection = new(); public uint LocalPlayerGuid; public Func TemplateResolver = FakeRowResolver; + public Func ResolveString = static (_, _) => null; public SocialFellowshipPageController.Bindings Build() => new( Snapshot: () => Snapshot, @@ -142,7 +143,7 @@ public sealed class SocialFellowshipPageControllerTests 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); + ResolveString: ResolveString); } // ── Empty/full frame swap (still correct after the FA4 rewrite) ──────── @@ -240,8 +241,21 @@ public sealed class SocialFellowshipPageControllerTests Assert.Equal(0.10f, health.Fill()!.Value, 3); } + /// + /// Fix-round SF-5: RENAMED from + /// ..._ButPreservesScrollPosition — with only one short row this + /// test cannot observe a NONZERO preserved offset (its own prior + /// comment admitted as much), so the "preserves scroll position" half + /// of the old name was aspirational, not verified here. The actual + /// shrink/clamp math IS covered, correctly, at the widget level + /// (UiTemplateListBoxFlushPreservingScrollTests.FlushPreservingScroll_ClampsToTheNewShorterContent). + /// This test's real job — proven below — is that a genuine roster + /// CHANGE (Bob joins) goes through + /// (not the scroll-resetting ) and + /// produces the right row count. + /// [Fact] - public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition() + public void Tick_MembershipChange_RebuildsRoster() { UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _); var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); @@ -256,13 +270,6 @@ public sealed class SocialFellowshipPageControllerTests 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]; @@ -271,6 +278,196 @@ public sealed class SocialFellowshipPageControllerTests Assert.Equal(2, listBox.ViewportForTest!.Children.Count); } + /// Fix-round SF-5: the shrink counterpart of the join test + /// above — a member LEAVING must also rebuild (not silently desync), + /// and a departed fellow's panel selection must clear rather than stay + /// stale (so a subsequent Dismiss/Leader click can't re-target a guid + /// that is no longer on the roster). + [Fact] + public void Tick_MemberLeaves_ShrinksRoster_AndClearsSelectionIfTheyWereSelected() + { + UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _); + var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); + var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false); + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot + { + IsInFellowship = true, Revision = 1, MemberCount = 2, LeaderGuid = 0x50000001u, + }, + Members = [alice, bob], + LocalPlayerGuid = 0x50000001u, // Alice, the leader + }; + + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; + Assert.Equal(2, listBox.ViewportForTest!.Children.Count); + + UiElement bobRow = listBox.ViewportForTest!.Children[1]; + UiText bobName = Assert.IsType(UiElement.FindDescendant(bobRow, RowNameTextId)); + bobName.OnClick!(); // selects Bob + ((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!(); + Assert.Contains("dismiss:50000002", b.Calls); + b.Calls.Clear(); + + // Bob leaves (independently of the click above — e.g. he quit). + b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 1 }; + b.Members = [alice]; + controller.Tick(); + + Assert.Single(listBox.ViewportForTest!.Children); + + // The stale selection must be gone — Dismiss must not re-send + // Bob's guid. + ((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!(); + Assert.DoesNotContain(b.Calls, c => c.StartsWith("dismiss:")); + } + + /// Fix-round SF-2: a permanently-unbuildable row template must + /// be attempted once, at the real membership change, not re-attempted + /// (a full DAT-locked + /// rebuild) on every subsequent pure-vitals revision bump. Without the + /// fix, comparing against _rows.Count (which stays 0 forever + /// here) makes every Tick see a "membership change" that never actually + /// happened. + [Fact] + public void Tick_RowTemplatePermanentlyFailsToBuild_DoesNotRetryOnEveryVitalsTick() + { + UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _); + var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); + int resolverCalls = 0; + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 }, + Members = [member], + TemplateResolver = (_, _) => { resolverCalls++; return null; }, // permanently unbuildable + }; + + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; + Assert.Empty(listBox.ViewportForTest?.Children ?? []); + int callsAfterFirstAttempt = resolverCalls; + Assert.True(callsAfterFirstAttempt >= 1); + + // Two pure vitals ticks: SAME member set, revision bumps (the exact + // shape a 0x02C0 vitals refresh produces). + b.Snapshot = b.Snapshot with { Revision = 2 }; + controller.Tick(); + b.Snapshot = b.Snapshot with { Revision = 3 }; + controller.Tick(); + + Assert.Equal(callsAfterFirstAttempt, resolverCalls); + } + + /// Fix-round SF-3: Recruit's "already a fellow" check must read + /// TRUE membership, not which rows happen to have finished building — + /// otherwise a permanently-unbuildable row lets Recruit light up for + /// someone who IS already a fellow. + [Fact] + public void RecruitButton_TargetIsAnExistingFellowWhoseRowFailedToBuild_StaysDisabled() + { + UiElement root = BuildPageRoot(out _, out _); + var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); + var selection = new SelectionState(); + selection.Select(0x50000001u, SelectionChangeSource.World); // targeting the (already-a-fellow) guid + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 1u }, + Members = [member], + LocalPlayerGuid = 1u, + Selection = selection, + TemplateResolver = (_, _) => null, // Alice's row never builds -- but she IS still a fellow + }; + + SocialFellowshipPageController.Bind(root, b.Build()); + + Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled); + } + + // ── MUST-FIX 4: world→panel selection sync ────────────────────────── + + [Fact] + public void WorldSelectionOfAFellow_EnablesDismissAndLeader_WithoutClickingTheirRow() + { + UiElement root = BuildPageRoot(out _, out _); + var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); + var selection = new SelectionState(); + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu }, + Members = [member], + LocalPlayerGuid = 0xFFu, // leader + Selection = selection, + }; + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; + Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled); + Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled); + + // MUST-FIX 4 — selecting Alice in the WORLD (not clicking her panel + // row) must still enable Dismiss/Leader, matching retail's + // gmFellowshipUI::UpdateFellowSelection reverse arm. + selection.Select(0x50000001u, SelectionChangeSource.World); + controller.Tick(); + + Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled); + Assert.True(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled); + } + + [Fact] + public void WorldSelectionOfANonFellow_DoesNotClearAnExistingPanelSelection() + { + UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _); + var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false); + var selection = new SelectionState(); + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu }, + Members = [member], + LocalPlayerGuid = 0xFFu, + Selection = selection, + }; + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; + + UiElement row = Assert.Single(listBox.ViewportForTest!.Children); + UiText name = Assert.IsType(UiElement.FindDescendant(row, RowNameTextId)); + name.OnClick!(); // selects Alice via her row + controller.Tick(); // Enabled is refreshed by RefreshButtonStates, which only runs in Tick + Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled); + + // Retail's fallback arm: a world selection that is NOT a fellow + // must not clear the panel's own selection while that fellow is + // still on the roster. + selection.Select(0x99999999u, SelectionChangeSource.World); + controller.Tick(); + + Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled); + } + + // ── N-0: optimistic Open/Close caption ────────────────────────────── + + [Fact] + public void OpenButton_Click_FlipsCaptionImmediately_NotWaitingForTheNextTick() + { + UiElement root = BuildPageRoot(out _, out _); + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u }, + LocalPlayerGuid = 1u, + ResolveString = (_, hash) => + hash == DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText") ? "Open" + : hash == DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText") ? "Close" + : null, + }; + SocialFellowshipPageController.Bind(root, b.Build()); + var openButton = (UiButton)UiElement.FindDescendant(root, OpenButtonId)!; + Assert.Equal("Open", openButton.Label); + + openButton.OnClick!(); + + // N-0 (fix round): retail's Open button handler pre-toggles its own + // state before the server echo (0x02BE) lands (lane B feature 11) + // -- the caption flips on click, not on the next Tick. + Assert.Equal("Close", openButton.Label); + } + [Fact] public void Tick_NotInFellowship_ClearsAnyStaleRoster() { @@ -294,8 +491,17 @@ public sealed class SocialFellowshipPageControllerTests [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, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> truncate(31.11) = 31 [InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented % + // MUST-FIX 1 (fix round): retail TRUNCATES (_ftol2 @0x0048ECC9), it + // does not round. The stored float constants for 6 and 8 fellows are + // 0.44999998807907104 and 0.3499999940395355 (byte-read from the + // PDB-paired binary) -- ×100 = 44.999998.../34.999999..., which + // truncate to 44/34, NOT 45/35 (what MathF.Round -- or even a naive + // single-precision (int)(pct*100f) cast, since 0.45f*100f already + // rounds UP to exactly 45.0f in single precision -- would produce). + [InlineData(true, true, 6, "12 44%")] + [InlineData(true, true, 8, "12 34%")] public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected) { UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);