feat(runtime): FA4 -- fellowship roster enumeration + Social selection source

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 04:40:19 +02:00
parent afb3223c9b
commit 357d203202
4 changed files with 88 additions and 0 deletions

View file

@ -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

View file

@ -154,6 +154,18 @@ public interface IRuntimeFellowshipView
RuntimeFellowshipSnapshot Snapshot { get; }
bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member);
/// <summary>
/// Campaign FA slice FA4: the complete member roster, for the
/// fellowship page's row-per-fellow list. <see cref="TryGetMember"/>
/// alone cannot build a roster — it needs the guid first. Order is
/// insertion order (the last <c>0x02BE FellowshipFullUpdate</c>'s
/// wire order, then whatever order incremental <c>0x02C0</c> 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.
/// </summary>
IEnumerable<RuntimeFellowMemberSnapshot> GetMembers();
}
public readonly record struct RuntimeAllegianceMemberSnapshot(

View file

@ -406,6 +406,24 @@ public sealed class RuntimeFellowshipState : IDisposable
}
}
/// <summary>
/// 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.
/// </summary>
public IEnumerable<RuntimeFellowMemberSnapshot> 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(