feat(ui): FA5 -- allegiance page fully live: CF-1 subscription, blocks, roster, swear/break/kick

Campaign FA slice FA5. The Allegiance page (0x10000291) goes from FA3's
empty-state shell to fully live, wired against FA1's parser and FA2's
RuntimeAllegianceState/IRuntimeAllegianceCommands (both already shipped
the full command surface, including SetUpdateSubscription).

CF-1 (the corrected data subscription): 0x001F AllegianceUpdateRequest,
not 0x027B, is the panel's data source (0x027B/0x027C are text-only chat
per FA2 MF-2). Wired at retail's three arming points -- Bind's PostInit
attempt (almost always a pre-world no-op), the post-world EnteredWorld
seam (RedeclareAfterWorldEntry, UNCONDITIONAL -- does not check the
current latch, matching retail's own PlayerDescReceived arm and avoiding
the exact MF-3-REOPEN bug class FA4 hit for 0x00A6), and the visible
branch (SetPageVisible, edge-triggered, folded into
SocialPanelController's existing window-shown+active-tab conjunction
alongside Fellowship's 0x00A6).

Monarch/patron/self blocks: per-relationship empty-state gate (fix-round
SF-7) replacing FA3's coarse HasProfile-only gate -- the monarch block
hides when there is no monarch OR the monarch is the viewer; the patron
block hides when there is no patron OR the patron is the monarch (in
which case the monarch block's 0x10000490 sub-block reveals and its
label swaps to PatronSlashMonarchLabel). Field sources decompiled fresh
from gmAllegianceUI::UpdatePlayerData/UpdateMonarchData/UpdatePatronData:
0x10000251 is the ALLEGIANCE's own name (not the viewer's), follower
counts are TotalVassals/TotalMembers-1 directly off the wire, and the
"experience passed up" text (0x10000492, doubled -- scoped FindDescendant
under each of its two parents) is the viewer's own CpTithed under the
monarch/patron blocks and each vassal's own CpTithed in their row.

Vassal roster: flat list built via UiTemplateListBox.FlushPreservingScroll
in the FA4 roster-diff pattern (guid-set diff, in-place update on an
unchanged set), rendering in the bindings' own already-reversed order.

Swear/break/kick: each opens a local confirmation dialog
(RetailDialogFactory via ShowConfirmation) before sending, mirroring
retail's MakeSwearConfirmationDialog family -- Swear targets the WORLD
selection (via the same ClientObjectTable name resolver
ToolbarRuntimeBindings.ResolveName already uses), Break targets the
current patron, Kick targets the panel-local selected vassal row (no
world-selection sync for Allegiance, unlike Fellowship). The
server-driven "accept incoming swear" (ConfirmationType 1) needed no new
code -- GameplayConfirmationController already handles every type
generically; a new test verifies it explicitly.

Runtime/composition plumbing: DeferredGameRuntimeStateCommands gains
Allegiance{Swear,Break,Kick,SetUpdateSubscription}; SocialRuntimeBindings
gains the Allegiance view/command projections; SocialPanelController.
Callbacks.AllegianceSnapshot widens to a full
SocialAllegiancePageController.Bindings record, mirroring FA4's
Fellowship widening.

Tests: SocialPanelControllerTests.cs gains 10 tests covering the SF-7
gate (4), roster population, swear/break/kick wiring (3), and the CF-1
subscription arming points (2); GameplayConfirmationControllerTests.cs
gains the type-1 verification test.
Also extends SocialPanelLiveMountProbeTests.cs (production-mount
assertions: scoped 0x10000492 resolution, the vassal row template, the
checkbox, confirmation-dialog string resolution, and a full production
Bind() pass) -- not yet run against live DATs in this worktree (no
Documents/Asheron's Call present here).

Release build green; full solution suite 13,296 passed / 4 skipped / 0
failed (13,300 total), up from FA4's 13,285/4/0 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 08:40:07 +02:00
parent f5bd3e5621
commit 7ed79eaf10
8 changed files with 1412 additions and 95 deletions

View file

@ -246,6 +246,20 @@ public sealed record OptionsRuntimeBindings(
/// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection).
/// </para>
///
/// <para>
/// Campaign FA slice FA5 adds the Allegiance page's write/read surface,
/// mirroring FA4's Fellowship shape: <see cref="AllegianceMonarch"/>/
/// <see cref="AllegiancePatron"/>/<see cref="AllegianceMember"/>/
/// <see cref="AllegianceVassals"/> project
/// <see cref="AcDream.Runtime.IRuntimeAllegianceView"/>'s <c>out</c>-param
/// accessors into plain nullable-returning delegates (the shape
/// <see cref="SocialAllegiancePageController.Bindings"/> wants), and
/// <see cref="AllegianceSwear"/>/<see cref="AllegianceBreak"/>/
/// <see cref="AllegianceKick"/>/<see cref="AllegianceSetUpdateSubscription"/>
/// route through <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
/// exactly like the fellowship commands above.
/// </para>
/// </summary>
public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
@ -261,7 +275,15 @@ public sealed record SocialRuntimeBindings(
Func<bool, RuntimeCommandResult> FellowshipSetOpen,
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid);
Func<uint> LocalPlayerGuid,
Func<AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegianceMonarch,
Func<uint, AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegiancePatron,
Func<uint, AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegianceMember,
Func<uint, IEnumerable<AcDream.Runtime.RuntimeAllegianceMemberSnapshot>> AllegianceVassals,
Func<uint, RuntimeCommandResult> AllegianceSwear,
Func<uint, RuntimeCommandResult> AllegianceBreak,
Func<uint, RuntimeCommandResult> AllegianceKick,
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -2773,7 +2795,30 @@ public sealed class RetailUiRuntime : IDisposable
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
Allegiance: new Layout.SocialAllegiancePageController.Bindings(
Snapshot: _bindings.Social.AllegianceSnapshot,
Monarch: _bindings.Social.AllegianceMonarch,
Patron: _bindings.Social.AllegiancePatron,
Member: _bindings.Social.AllegianceMember,
Vassals: _bindings.Social.AllegianceVassals,
Swear: _bindings.Social.AllegianceSwear,
Break: _bindings.Social.AllegianceBreak,
Kick: _bindings.Social.AllegianceKick,
SetUpdateSubscription: _bindings.Social.AllegianceSetUpdateSubscription,
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)),
TemplateResolver: TemplateResolver,
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId),
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) — the same
// ClientObjectTable resolver ToolbarRuntimeBindings.ResolveName
// already wires (Swear's target is a WORLD selection, not yet
// an allegiance member, so the allegiance-profile accessors
// above cannot name them).
ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(),
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);