From 357d2032020b9a4cbec305fa80d48877c9bf18d8 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 04:40:19 +0200 Subject: [PATCH] feat(runtime): FA4 -- fellowship roster enumeration + Social selection source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IRuntimeFellowshipView.GetMembers() gives the fellowship panel a way to enumerate the whole roster (TryGetMember alone needs the guid first, which a UI roster build doesn't have yet). Implemented on RuntimeFellowshipState.FellowshipView as a materialized snapshot under the same lock every other read there uses. SelectionChangeSource.Social covers a fellowship-roster row click (gmFellowshipUI's list-selection arm calls the same ACCWeenieObject::SetSelectedObject primitive every other selection origin uses -- lane B docs/research/2026-08-11-fa-panel-structure.md §6.2/§2.8). Runtime tests: +4 (RuntimeFellowshipStateTests.GetMembers_*). Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Selection/SelectionState.cs | 6 +++ .../GameRuntimeGameplayViews.cs | 12 +++++ .../Gameplay/RuntimeFellowshipState.cs | 18 +++++++ .../Gameplay/RuntimeFellowshipStateTests.cs | 52 +++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index d1abebc1..8879a3b8 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -18,6 +18,12 @@ public enum SelectionChangeSource // ACCWeenieObject::SetSelectedObject primitive as every other origin // (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one. Vendor, + // Campaign FA slice FA4: a fellowship-roster row click. Retail's + // gmFellowshipUI::ListenToElementMessage list-selection arm + // (docs/research/2026-08-11-fa-panel-structure.md §6.2/§2.8) calls + // ACCWeenieObject::SetSelectedObject on the SAME global primitive — + // clicking a fellow in the panel selects them in the world too. + Social, } public enum SelectionChangeReason diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs index 1695b7d2..ade50b95 100644 --- a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs +++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs @@ -154,6 +154,18 @@ public interface IRuntimeFellowshipView RuntimeFellowshipSnapshot Snapshot { get; } bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member); + + /// + /// Campaign FA slice FA4: the complete member roster, for the + /// fellowship page's row-per-fellow list. + /// alone cannot build a roster — it needs the guid first. Order is + /// insertion order (the last 0x02BE FellowshipFullUpdate's + /// wire order, then whatever order incremental 0x02C0 upserts + /// added new members) — retail's own hash-table iteration order is not + /// a stable contract either (lane B §3.9), so no ordering guarantee + /// beyond "stable across repeated calls between mutations" is made. + /// + IEnumerable GetMembers(); } public readonly record struct RuntimeAllegianceMemberSnapshot( diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs index 9e800647..b0d9e962 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs @@ -406,6 +406,24 @@ public sealed class RuntimeFellowshipState : IDisposable } } + /// + /// Campaign FA slice FA4: snapshots the whole roster under the same + /// lock every other read here uses. Returns a materialized array + /// (not a lazy iterator over live state) so the caller can walk it + /// after the lock releases without racing a concurrent mutation. + /// + public IEnumerable GetMembers() + { + lock (owner._gate) + { + var result = new RuntimeFellowMemberSnapshot[owner._members.Count]; + int i = 0; + foreach (GameEvents.FellowMember raw in owner._members.Values) + result[i++] = ToSnapshot(raw); + return result; + } + } + private static RuntimeFellowMemberSnapshot ToSnapshot( GameEvents.FellowMember raw) => new( diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs index 2ef2bd64..562b9ed1 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs @@ -482,6 +482,58 @@ public sealed class RuntimeFellowshipStateTests Assert.Equal(42u, self.CurrentHealth); } + // ── Campaign FA slice FA4: GetMembers (the fellowship panel's roster + // enumeration — TryGetMember alone cannot build a roster without the + // guid first). ──────────────────────────────────────────────────────── + + [Fact] + public void GetMembers_ReturnsEveryCurrentMember() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid, "Self"), Member(LeaderGuid, "Leader"), Member(OtherGuid, "Other"))); + + var guids = state.View.GetMembers().Select(m => m.Guid).ToHashSet(); + + Assert.Equal(3, guids.Count); + Assert.Contains(SelfGuid, guids); + Assert.Contains(LeaderGuid, guids); + Assert.Contains(OtherGuid, guids); + } + + [Fact] + public void GetMembers_EmptyWhenNotInAFellowship() + { + var state = new RuntimeFellowshipState(); + + Assert.Empty(state.View.GetMembers()); + } + + [Fact] + public void GetMembers_ReflectsAnIncrementalUpsert() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid))); + + state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow( + OtherGuid, Member(OtherGuid, "Other"), UpdateType: 1u)); + + Assert.Equal(3, state.View.GetMembers().Count()); + Assert.Contains(state.View.GetMembers(), m => m.Guid == OtherGuid && m.Name == "Other"); + } + + [Fact] + public void GetMembers_ReflectsRemovalAfterDismiss() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid), Member(OtherGuid))); + + state.ApplyDismiss(OtherGuid, SelfGuid); + + Assert.DoesNotContain(state.View.GetMembers(), m => m.Guid == OtherGuid); + Assert.Equal(2, state.View.GetMembers().Count()); + } + private sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 8, 12, 0, 0, 0, TimeSpan.Zero);