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:
parent
afb3223c9b
commit
357d203202
4 changed files with 88 additions and 0 deletions
|
|
@ -18,6 +18,12 @@ public enum SelectionChangeSource
|
||||||
// ACCWeenieObject::SetSelectedObject primitive as every other origin
|
// ACCWeenieObject::SetSelectedObject primitive as every other origin
|
||||||
// (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one.
|
// (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one.
|
||||||
Vendor,
|
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
|
public enum SelectionChangeReason
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,18 @@ public interface IRuntimeFellowshipView
|
||||||
RuntimeFellowshipSnapshot Snapshot { get; }
|
RuntimeFellowshipSnapshot Snapshot { get; }
|
||||||
|
|
||||||
bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member);
|
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(
|
public readonly record struct RuntimeAllegianceMemberSnapshot(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
private static RuntimeFellowMemberSnapshot ToSnapshot(
|
||||||
GameEvents.FellowMember raw) =>
|
GameEvents.FellowMember raw) =>
|
||||||
new(
|
new(
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,58 @@ public sealed class RuntimeFellowshipStateTests
|
||||||
Assert.Equal(42u, self.CurrentHealth);
|
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 sealed class ManualTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
private DateTimeOffset _now = new(2026, 8, 12, 0, 0, 0, TimeSpan.Zero);
|
private DateTimeOffset _now = new(2026, 8, 12, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue