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

@ -897,7 +897,29 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
d.Actions.Selection,
() => d.PlayerIdentity.ServerGuid),
() => d.PlayerIdentity.ServerGuid,
// Campaign FA slice FA5: IRuntimeAllegianceView's out-param
// accessors projected into nullable-returning delegates —
// the same shape SocialAllegiancePageController.Bindings
// wants, mirroring the Fellowship projection above.
AllegianceMonarch: () =>
d.Runtime.Allegiance.TryGetMonarch(out var monarch)
? monarch
: (RuntimeAllegianceMemberSnapshot?)null,
AllegiancePatron: guid =>
d.Runtime.Allegiance.TryGetPatron(guid, out var patron)
? patron
: (RuntimeAllegianceMemberSnapshot?)null,
AllegianceMember: guid =>
d.Runtime.Allegiance.TryGetMember(guid, out var member)
? member
: (RuntimeAllegianceMemberSnapshot?)null,
AllegianceVassals: guid => d.Runtime.Allegiance.GetVassals(guid),
AllegianceSwear: guid => late.GameRuntime.AllegianceSwear(guid),
AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -158,6 +158,29 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
generation, panelOpen));
// ── Campaign FA slice FA5: allegiance page commands ─────────────────
// Same shape as the fellowship block above.
public RuntimeCommandResult AllegianceSwear(uint patronGuid) =>
Invoke((commands, generation) => commands.Allegiance.Swear(
generation, patronGuid));
public RuntimeCommandResult AllegianceBreak(uint targetGuid) =>
Invoke((commands, generation) => commands.Allegiance.Break(
generation, targetGuid));
public RuntimeCommandResult AllegianceKick(uint vassalGuid) =>
Invoke((commands, generation) => commands.Allegiance.Kick(
generation, vassalGuid));
/// <summary><c>0x001F</c> — CF-1's data subscription toggle (the
/// allegiance-page analogue of <see cref="FellowshipSetPanelOpen"/>'s
/// <c>0x00A6</c>). See <see cref="AcDream.App.UI.Layout.SocialAllegiancePageController"/>
/// for the three retail arming points this forwards.</summary>
public RuntimeCommandResult AllegianceSetUpdateSubscription(bool on) =>
Invoke((commands, generation) => commands.Allegiance.SetUpdateSubscription(
generation, on));
public void Deactivate()
{
lock (_gate)

View file

@ -1,137 +1,782 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3: the Allegiance page's empty-state presentation —
/// the ONLY behavior this SHELL slice owns for this page (live monarch/
/// patron/vassal population, swear/break/kick + confirmations, and the
/// per-member online-state dimming are FA5 scope).
/// Campaign FA slice FA5: the Allegiance page fully live — the <c>0x001F</c>
/// data subscription (CF-1), monarch/patron/self blocks, the flat vassal
/// list, swear/break/kick + their local confirmation dialogs, and the
/// per-relationship empty-state gate FA3's fix round flagged as owed
/// (mechanism SF-7). FA3 shipped only the coarse
/// <see cref="RuntimeAllegianceSnapshot.HasProfile"/> gate — see that
/// slice's own class doc, now superseded by this one.
///
/// <para>
/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it
/// hides the monarch/patron blocks and blanks their name text to a literal
/// space per-block, gated on whether each relationship exists
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract
/// simplifies this to the single gate this campaign's Runtime owner
/// actually exposes today: <see cref="RuntimeAllegianceSnapshot.HasProfile"/>
/// — no allegiance push has landed this generation. When a profile HAS
/// arrived, this controller leaves both blocks visible with their
/// build-time (empty) text rather than fabricate monarch/patron content —
/// that population is FA5's job.
/// <b>CF-1 — the data subscription.</b>
/// <c>docs/research/2026-08-11-fa-allegiance-wire.md</c> §1.2 pins THREE
/// retail arming points for <c>CM_Allegiance::Event_UpdateRequest(u32)</c>
/// (opcode <c>0x001F</c>), all sending <c>1</c> except the last:
/// <c>gmAllegianceUI::PostInit @0x004911C6</c>, <c>RecvNotice_PlayerDescReceived
/// @0x00490D40</c>, and <c>OnVisibilityChanged</c>'s visible/hidden branches
/// (<c>@0x004912DD</c>/<c>@0x00491311</c>). <see cref="Bind"/> attempts the
/// PostInit arm (almost always a pre-world no-op in this campaign's
/// process-lifetime mount — see <see cref="RedeclareAfterWorldEntry"/>'s own
/// doc for why that is not a gap); <see cref="RedeclareAfterWorldEntry"/> is
/// the PlayerDescReceived arm, wired to the LiveSession <c>EnteredWorld</c>
/// seam exactly like FA4's <c>0x00A6</c>; <see cref="SetPageVisible"/> is the
/// visible-branch arm, called by <see cref="SocialPanelController"/> on every
/// "window shown AND Allegiance active" transition. Without this,
/// <c>0x0020 AllegianceUpdate</c> never arrives on demand (lane C §2 row 4/5)
/// and the panel shows nothing — <c>0x027B AllegianceInfoResponse</c> is
/// text-only chat (FA2 MF-2's correction) and is NOT wired here.
/// </para>
///
/// <para>
/// Element ids confirmed by the FA3 live-mount probe:
/// <c>0x10000255</c> monarch-field container, <c>0x1000025A</c>
/// patron-field container, <c>0x10000257</c> monarch-name text (inside the
/// monarch field), <c>0x1000025C</c> patron-name text (inside the patron
/// field). Both lookups are SCOPED to the allegiance page root — the panel
/// also authors <c>0x10000492</c> (the XP-passed-up text) TWICE, once under
/// each block, so a flat lookup anywhere in this subsystem would risk
/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP
/// <c>0x10000211</c>-in-two-layouts lesson).
/// <b>The FA4 MF-3-REOPEN lesson, applied here.</b> A reconnect's generation
/// reset runs BEFORE the new session is in world, where every Runtime
/// command is world-gated (<c>Inactive</c>, nothing sent).
/// <see cref="ResetPageVisibleLatch"/> (wired to the pre-world reset seam)
/// ONLY clears the local latch — it must never itself declare.
/// <see cref="RedeclareAfterWorldEntry"/> (wired to the POST-world seam)
/// does the actual send, and — unlike <see cref="SetPageVisible"/> — is
/// UNCONDITIONAL: it does not compare against the current latch first,
/// because retail's own <c>RecvNotice_PlayerDescReceived</c> arm does not
/// check current visibility either (§1.2's table: it always sends <c>1</c>).
/// The latch still advances "only on Accepted" (FA4's re-fix rule), so a
/// dropped/rejected send leaves the latch clear for a later retry rather
/// than lying about what was actually published.
/// </para>
///
/// <para>
/// <b>FA5 scope note (fix-round mechanism SF-7).</b> This shell's gate is
/// coarser than retail's: <c>gmAllegianceUI::UpdateMonarchData
/// @0x00491B40</c> hides the monarch block when there is no monarch OR the
/// monarch IS the viewer, and hides the patron block on the analogous
/// per-relationship test — this controller instead gates BOTH blocks on
/// the single <see cref="RuntimeAllegianceSnapshot.HasProfile"/> flag. The
/// plan's FA5 row records the two per-relationship acceptance lines FA5
/// owes. Also note for whoever lands FA5: <see cref="Tick"/> reassigns
/// <see cref="UiText.LinesProvider"/> UNCONDITIONALLY every frame — FA5's
/// real monarch/patron name population must change this method at the
/// same time, or its content will be overwritten on the very next frame.
/// <b>Per-relationship empty-state gate (fix-round SF-7).</b>
/// <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c> hides the monarch
/// block when there is no monarch OR the monarch IS the viewer;
/// <c>UpdatePatronData @0x004917C0</c> hides the patron block when there is
/// no patron OR the patron IS the monarch (in which case the monarch block
/// relabels via <c>ID_Allegiance_PatronSlashMonarchLabel</c> and reveals its
/// <c>0x10000490</c> sub-block, byte-verified at the same two functions).
/// Both hidden branches blank their name text to a literal single space —
/// <c>UpdateMonarchData</c> ALSO blanks the monarch-followers text in its
/// hidden branch (<c>@0x00491FCD</c>), a detail the coarser FA3 gate did not
/// need to model.
/// </para>
///
/// <para>
/// <b>Numeric-only text, no invented English (register row AD-85).</b> Every
/// retail string site beyond a bare name or a variable-free caption
/// (<c>ID_Allegiance_MonarchLabel</c>/<c>PatronSlashMonarchLabel</c>, which
/// carry NO <c>StringInfo</c> variables and so resolve and render exactly as
/// authored) needs <c>StringInfo</c> variable substitution acdream has not
/// ported (the same gap AD-81 filed for the Fellowship page). This
/// controller therefore renders followers/rank/experience-passed-up as bare
/// numbers with no surrounding words — the retail-authored NUMBERS, never
/// invented sentences — and the three local confirmation dialogs
/// (Swear/Break/Kick) show retail's own unsubstituted template text
/// verbatim when it resolves, falling back to the bare target name (also
/// non-invented) when it does not.
/// </para>
///
/// <para>
/// <b>Field sources, decompiled (`gmAllegianceUI::UpdatePlayerData
/// @0x00491330`, <c>UpdateMonarchData</c>, <c>UpdatePatronData</c>).</b>
/// <c>0x10000251</c> = the ALLEGIANCE's own name (<c>_allegiance.m_AllegianceName</c>
/// — "CharacterName" in the id is a misnomer), not the viewer's own name.
/// <c>0x10000252</c> ("your followers") = <c>_total_vassals</c> directly.
/// <c>0x10000258</c> (monarch followers) = <c>_total_members - 1</c>. The
/// self-rank field (<c>0x10000253</c>) queries a LIVE buffed quality
/// (<c>CBaseQualities::InqInt(qualities, 0x1e)</c>, i.e.
/// <c>PropertyInt.AllegianceRank</c>) plus <c>AllegianceData::GetTitle</c>'s
/// 20-table title lookup — neither is ported here (the buffed/unbuffed
/// distinction needs a Character-system seam this controller does not have,
/// and the title table doesn't exist in acdream at all), so this controller
/// substitutes the numerically-equivalent <see cref="RuntimeAllegianceSnapshot.Rank"/>
/// (same <c>0x0020</c> message, same underlying stat in every observed case)
/// rendered bare. The "experience passed up" text (<c>0x10000492</c>, doubled
/// under <c>0x10000490</c> and the patron field) and the vassal row's own
/// <c>0x10000269</c> both source <c>AllegianceData::_cp_tithed</c> — the
/// VIEWER's own tithed amount under the monarch/patron blocks, the VASSAL's
/// own tithed amount in each row.
/// </para>
/// </summary>
public sealed class SocialAllegiancePageController
{
// ── Element ids (docs/research/2026-08-11-fa-panel-structure.md §3.2) ──
private const uint SelfNameTextId = 0x10000251u;
private const uint SelfFollowersTextId = 0x10000252u;
private const uint SelfRankTextId = 0x10000253u;
private const uint MonarchFieldId = 0x10000255u;
private const uint MonarchLabelTextId = 0x10000256u;
private const uint MonarchNameTextId = 0x10000257u;
private const uint MonarchFollowersTextId = 0x10000258u;
private const uint PatronFieldId = 0x1000025Au;
private const uint PatronNameTextId = 0x1000025Cu;
private const uint VassalListBoxId = 0x10000260u;
private const uint IgnoreRequestsCheckboxId = 0x10000262u;
private const uint SwearButtonId = 0x10000263u;
private const uint BreakButtonId = 0x10000264u;
private const uint KickButtonId = 0x10000265u;
/// <summary>Child of <see cref="MonarchFieldId"/> — shown only when the
/// viewer's patron IS the monarch (SF-7).</summary>
private const uint MonarchIsPatronSubBlockId = 0x10000490u;
/// <summary>Authored TWICE inside this page (§6 DISCIPLINE) — once under
/// <see cref="MonarchIsPatronSubBlockId"/>, once directly under
/// <see cref="PatronFieldId"/>. Every lookup MUST be scoped to the
/// correct parent; a flat <c>FindDescendant</c> from the page root would
/// non-deterministically pick one instance for both roles.</summary>
private const uint ExperiencePassedUpTextId = 0x10000492u;
// ── Row-template element ids (same doc — the 3-part vassal row) ──
private const uint RowNameTextId = 0x10000268u;
private const uint RowExperiencePassedUpTextId = 0x10000269u;
private const uint RowOfflineMarkerId = 0x100004AAu;
private const uint StringTableId = 0x23000001u;
private const uint OptionStringTableId = 0x23000003u;
private static readonly Vector4 TextColor = Vector4.One;
private static readonly Vector4 OfflineNameColor = new(0.6f, 0.6f, 0.6f, 1f);
private static readonly IReadOnlyList<UiText.Line> BlankLine =
[new UiText.Line(" ", System.Numerics.Vector4.One)];
private static readonly IReadOnlyList<UiText.Line> NoLines = [];
/// <summary>
/// Fix-round mechanism SF-2 / blast SF-4: hoisted <see cref="UiText.LinesProvider"/>
/// delegates. <see cref="Tick"/> runs every frame regardless of panel
/// visibility (see class doc); the original <c>() =&gt; lines</c> closure
/// captured a local and allocated a display class PLUS a delegate on
/// every call. These two cached delegates make the per-frame
/// reassignment a plain field write — zero allocation while idle.
/// </summary>
[new UiText.Line(" ", TextColor)];
private static readonly Func<IReadOnlyList<UiText.Line>> BlankLineProvider = () => BlankLine;
private static readonly Func<IReadOnlyList<UiText.Line>> NoLinesProvider = () => NoLines;
/// <summary>Sentinel passed to <see cref="SetProvider"/> for the "hidden,
/// blanked" state — deliberately NOT <see langword="null"/>, since the
/// backing <c>_lastXxx</c> fields also start at <see langword="null"/>
/// (uninitialized). Using <see langword="null"/> as both the sentinel
/// AND the initial value would make the very first blank-state
/// <see cref="Tick"/> a false "unchanged" no-op, leaving the widget at
/// its import-time (non-blank) text instead of retail's literal single
/// space.</summary>
private const string BlankSentinel = "blank";
/// <summary>The live read/write seam this page binds to — mirrors
/// <see cref="SocialFellowshipPageController.Bindings"/>'s shape.
/// <see cref="Monarch"/>/<see cref="Patron"/>/<see cref="Member"/>/
/// <see cref="Vassals"/> project <see cref="IRuntimeAllegianceView"/>'s
/// <c>out</c>-param accessors into plain nullable-returning delegates.
/// <see cref="ResolveWorldObjectName"/> is Swear's target-name source —
/// the target is a WORLD selection, not (yet) an allegiance member, so
/// the allegiance-profile accessors cannot name them (retail's own
/// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)</c>, the SAME
/// resolver <c>ToolbarRuntimeBindings.ResolveName</c> already wires from
/// <c>ClientObjectTable</c>).</summary>
public sealed record Bindings(
Func<RuntimeAllegianceSnapshot> Snapshot,
Func<RuntimeAllegianceMemberSnapshot?> Monarch,
Func<uint, RuntimeAllegianceMemberSnapshot?> Patron,
Func<uint, RuntimeAllegianceMemberSnapshot?> Member,
Func<uint, IEnumerable<RuntimeAllegianceMemberSnapshot>> Vassals,
Func<uint, RuntimeCommandResult> Swear,
Func<uint, RuntimeCommandResult> Break,
Func<uint, RuntimeCommandResult> Kick,
Func<bool, RuntimeCommandResult> SetUpdateSubscription,
AcDream.Core.Selection.SelectionState Selection,
Func<uint> LocalPlayerGuid,
Func<CharacterOptionId, bool> CurrentCharacterOption,
Action<CharacterOptionId, bool> SetCharacterOption,
Func<uint, uint, UiElement?> TemplateResolver,
Func<uint, uint, string?> ResolveString,
Func<uint, string?> ResolveWorldObjectName,
Func<string, Action<bool>, uint> ShowConfirmation);
private readonly record struct VassalRowWidgets(
UiText? Name,
UiText? ExperiencePassedUp,
UiElement? OfflineMarker);
private readonly Bindings _bindings;
private readonly UiText? _selfName;
private readonly UiText? _selfFollowers;
private readonly UiText? _selfRank;
private readonly UiElement _monarchField;
private readonly UiElement _patronField;
private readonly UiText? _monarchLabel;
private readonly UiText? _monarchName;
private readonly UiText? _monarchFollowers;
private readonly UiElement? _monarchIsPatronSubBlock;
private readonly UiText? _monarchExperiencePassedUp;
private readonly UiElement _patronField;
private readonly UiText? _patronName;
private readonly Func<RuntimeAllegianceSnapshot> _snapshot;
private readonly UiText? _patronExperiencePassedUp;
private readonly UiTemplateListBox? _vassalListBox;
private readonly UiButton? _ignoreRequestsCheckbox;
private readonly UiButton? _swearButton;
private readonly UiButton? _breakButton;
private readonly UiButton? _kickButton;
/// <summary>Bind-time-resolved (never per-tick — same discipline every
/// other <c>DatStringResolver</c> consumer in this codebase follows).
/// Null when resolution failed — the affected widget then keeps its
/// import-time text/caption rather than showing invented English.</summary>
private readonly string? _monarchLabelCaption;
private readonly string? _patronSlashMonarchLabelCaption;
private readonly string? _swearConfirmationTemplate;
private readonly string? _breakConfirmationTemplate;
private readonly string? _kickConfirmationTemplate;
private readonly Dictionary<uint, VassalRowWidgets> _rows = new();
private readonly HashSet<uint> _vassalGuids = new();
private uint _selectedVassalGuid;
private long _lastRosterRevision = long.MinValue;
/// <summary>CF-1's local latch — see the class doc's CF-1 section.
/// Advances only on <see cref="RuntimeCommandStatus.Accepted"/>.</summary>
private bool _subscribed;
private string? _lastSelfName;
private string? _lastSelfFollowers;
private string? _lastSelfRank;
private string? _lastMonarchName;
private string? _lastMonarchFollowers;
private string? _lastMonarchExperiencePassedUp;
private string? _lastPatronName;
private string? _lastPatronExperiencePassedUp;
private SocialAllegiancePageController(
Bindings bindings,
UiText? selfName,
UiText? selfFollowers,
UiText? selfRank,
UiElement monarchField,
UiElement patronField,
UiText? monarchLabel,
UiText? monarchName,
UiText? monarchFollowers,
UiElement? monarchIsPatronSubBlock,
UiText? monarchExperiencePassedUp,
UiElement patronField,
UiText? patronName,
Func<RuntimeAllegianceSnapshot> snapshot)
UiText? patronExperiencePassedUp,
UiTemplateListBox? vassalListBox,
UiButton? ignoreRequestsCheckbox,
UiButton? swearButton,
UiButton? breakButton,
UiButton? kickButton,
string? monarchLabelCaption,
string? patronSlashMonarchLabelCaption,
string? swearConfirmationTemplate,
string? breakConfirmationTemplate,
string? kickConfirmationTemplate)
{
_bindings = bindings;
_selfName = selfName;
_selfFollowers = selfFollowers;
_selfRank = selfRank;
_monarchField = monarchField;
_patronField = patronField;
_monarchLabel = monarchLabel;
_monarchName = monarchName;
_monarchFollowers = monarchFollowers;
_monarchIsPatronSubBlock = monarchIsPatronSubBlock;
_monarchExperiencePassedUp = monarchExperiencePassedUp;
_patronField = patronField;
_patronName = patronName;
_snapshot = snapshot;
_patronExperiencePassedUp = patronExperiencePassedUp;
_vassalListBox = vassalListBox;
_ignoreRequestsCheckbox = ignoreRequestsCheckbox;
_swearButton = swearButton;
_breakButton = breakButton;
_kickButton = kickButton;
_monarchLabelCaption = monarchLabelCaption;
_patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption;
_swearConfirmationTemplate = swearConfirmationTemplate;
_breakConfirmationTemplate = breakConfirmationTemplate;
_kickConfirmationTemplate = kickConfirmationTemplate;
}
public static SocialAllegiancePageController? Bind(
UiElement pageRoot,
Func<RuntimeAllegianceSnapshot> snapshot)
public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField)
if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field "
+ "containers (0x10000255/0x1000025A) not found — allegiance page "
+ "will not present its empty state.");
+ $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — "
+ "allegiance page will not present.");
return null;
}
// Scoped to each block — 0x10000257/0x1000025C are unique per-block,
// but the lookup discipline matters equally here (see class doc).
UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText;
UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText;
if (monarchName is null)
Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found.");
if (patronName is null)
Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found.");
// Scoped lookups — see class doc + §6 DISCIPLINE. 0x10000492 is
// authored TWICE; each instance is resolved under its OWN parent.
UiElement? monarchIsPatronSubBlock =
UiElement.FindDescendant(monarchField, MonarchIsPatronSubBlockId);
UiText? monarchExperiencePassedUp = monarchIsPatronSubBlock is null
? null
: UiElement.FindDescendant(monarchIsPatronSubBlock, ExperiencePassedUpTextId) as UiText;
UiText? patronExperiencePassedUp =
UiElement.FindDescendant(patronField, ExperiencePassedUpTextId) as UiText;
UiText? selfName = UiElement.FindDescendant(pageRoot, SelfNameTextId) as UiText;
UiText? selfFollowers = UiElement.FindDescendant(pageRoot, SelfFollowersTextId) as UiText;
UiText? selfRank = UiElement.FindDescendant(pageRoot, SelfRankTextId) as UiText;
UiText? monarchLabel = UiElement.FindDescendant(monarchField, MonarchLabelTextId) as UiText;
UiText? monarchName = UiElement.FindDescendant(monarchField, MonarchNameTextId) as UiText;
UiText? monarchFollowers = UiElement.FindDescendant(monarchField, MonarchFollowersTextId) as UiText;
UiText? patronName = UiElement.FindDescendant(patronField, PatronNameTextId) as UiText;
UiTemplateListBox? vassalListBox =
UiElement.FindDescendant(pageRoot, VassalListBoxId) as UiTemplateListBox;
if (vassalListBox is null)
Console.WriteLine(
$"[D.2b] SocialAllegiancePageController: ListBox 0x{VassalListBoxId:X8} not "
+ "found — the vassal list will not populate.");
else
{
vassalListBox.TemplateResolver = bindings.TemplateResolver;
uint scrollbarElementId = vassalListBox.ScrollbarElementId;
UiElement? scrollbarElement = scrollbarElementId == 0
? null
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = vassalListBox.Scroll;
else
Console.WriteLine(
$"[D.2b] SocialAllegiancePageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the vassal list will not scroll.");
}
UiButton? ignoreRequestsCheckbox =
UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton;
UiButton? swearButton = UiElement.FindDescendant(pageRoot, SwearButtonId) as UiButton;
UiButton? breakButton = UiElement.FindDescendant(pageRoot, BreakButtonId) as UiButton;
UiButton? kickButton = UiElement.FindDescendant(pageRoot, KickButtonId) as UiButton;
// Bind-time only (class doc's numeric-only-text section). No
// StringInfo variables on either label — safe to use verbatim.
string? monarchLabelCaption = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_MonarchLabel"));
string? patronSlashMonarchLabelCaption = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_PatronSlashMonarchLabel"));
// AD-85: unsubstituted retail template text, used verbatim (never
// blended with an invented sentence) — see class doc.
string? swearConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_SwearConfirmation"));
string? breakConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_BreakConfirmation"));
string? kickConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_KickConfirmation"));
var controller = new SocialAllegiancePageController(
monarchField, patronField, monarchName, patronName, snapshot);
bindings,
selfName, selfFollowers, selfRank,
monarchField, monarchLabel, monarchName, monarchFollowers,
monarchIsPatronSubBlock, monarchExperiencePassedUp,
patronField, patronName, patronExperiencePassedUp,
vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton,
monarchLabelCaption, patronSlashMonarchLabelCaption,
swearConfirmationTemplate, breakConfirmationTemplate, kickConfirmationTemplate);
controller.WireButtons();
controller.WireCheckbox();
controller.Tick();
// CF-1's PostInit arm (gmAllegianceUI::PostInit @0x004911C6): almost
// always a pre-world no-op in this campaign's process-lifetime mount
// (Bind runs once, at UI construction, before any connection) — see
// RedeclareAfterWorldEntry for the arm that actually matters in
// practice. Attempted anyway for citation completeness and the rare
// case a host mounts post-world.
controller.SetPageVisible(true);
return controller;
}
private void WireButtons()
{
if (_swearButton is not null)
_swearButton.OnClick = OnSwearClick;
if (_breakButton is not null)
_breakButton.OnClick = OnBreakClick;
if (_kickButton is not null)
_kickButton.OnClick = OnKickClick;
}
private void WireCheckbox()
{
if (_ignoreRequestsCheckbox is null) return;
const string labelKey = "ID_PlayerOption_IgnoreAllegianceRequests";
string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey));
if (label is not null)
_ignoreRequestsCheckbox.Label = label;
else
Console.WriteLine(
$"[D.2b] SocialAllegiancePageController: label '{labelKey}' did not resolve — "
+ "checkbox renders with no caption rather than invented English.");
string? tooltip = _bindings.ResolveString(
OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
if (tooltip is not null)
_ignoreRequestsCheckbox.TooltipText = tooltip;
_ignoreRequestsCheckbox.OnClick = () =>
{
bool next = !_ignoreRequestsCheckbox.Selected;
_ignoreRequestsCheckbox.Selected = next;
_bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next);
};
}
/// <summary>
/// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
/// — called every frame from <see cref="SocialPanelController.Tick"/> (same
/// reasoning as <see cref="SocialFellowshipPageController.Tick"/>).
/// <c>gmAllegianceUI::UpdateSwearButton @0x004908E0</c>'s dialog half —
/// the TARGET is latched here, at click time (retail's own
/// <c>m_iidPossibleNewPatron</c> capture, lane C §1.3 step 2), not
/// re-read when the dialog closes. "No dialog if the name is empty"
/// (retail's own guard) is honored via the null/empty check below —
/// register row AD-84 covers the missing "target IS a player" enable-rule
/// check (mirrors AD-83's identical Recruit-button gap).
/// </summary>
private void OnSwearClick()
{
if (_bindings.Selection.SelectedObjectId is not { } targetGuid) return;
string? name = _bindings.ResolveWorldObjectName(targetGuid);
if (string.IsNullOrEmpty(name)) return;
string message = _swearConfirmationTemplate ?? name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Swear(targetGuid);
});
}
/// <summary><c>gmAllegianceUI::UpdateBreakButton @0x004909D0</c> +
/// <c>MakeBreakConfirmationDialog @0x00492BF0</c>: target = the viewer's
/// current patron, latched at click time.</summary>
private void OnBreakClick()
{
uint selfGuid = _bindings.LocalPlayerGuid();
if (_bindings.Patron(selfGuid) is not { } patron) return;
string message = _breakConfirmationTemplate ?? patron.Name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Break(patron.CharacterId);
});
}
/// <summary><c>MakeKickConfirmationDialog @0x00492E10</c>: target = the
/// currently-selected vassal ROW (panel-local selection,
/// <c>m_iidSelectedVassal</c> — NOT the world selection; lane A §6.2
/// confirms Allegiance's list-selection message has no
/// <c>ACCWeenieObject::SetSelectedObject</c> call, unlike Fellowship's).</summary>
private void OnKickClick()
{
if (_selectedVassalGuid == 0u) return;
if (_bindings.Member(_selectedVassalGuid) is not { } vassal) return;
uint vassalGuid = _selectedVassalGuid;
string message = _kickConfirmationTemplate ?? vassal.Name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Kick(vassalGuid);
});
}
/// <summary>
/// Re-reads live state and applies retail's per-relationship gate,
/// roster diff, and button-enable rules. Called every frame from
/// <see cref="SocialPanelController.Tick"/> unconditionally (same
/// reasoning FA3 established: cheap even while the panel is hidden — no
/// DAT access outside the revision-gated roster rebuild branch).
/// </summary>
public void Tick()
{
bool hasProfile = _snapshot().HasProfile;
_monarchField.Visible = hasProfile;
_patronField.Visible = hasProfile;
RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot();
uint selfGuid = _bindings.LocalPlayerGuid();
RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null;
RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null;
Func<IReadOnlyList<UiText.Line>> provider = hasProfile ? NoLinesProvider : BlankLineProvider;
if (_monarchName is not null) _monarchName.LinesProvider = provider;
if (_patronName is not null) _patronName.LinesProvider = provider;
RefreshSelfBlock(snapshot);
RefreshMonarchBlock(snapshot, monarch, patron);
RefreshPatronBlock(snapshot, monarch, patron);
if (snapshot.Revision != _lastRosterRevision)
{
_lastRosterRevision = snapshot.Revision;
RefreshRoster(selfGuid);
}
RefreshCheckboxSelection();
RefreshButtonStates(snapshot, selfGuid, patron);
}
// gmAllegianceUI::UpdatePlayerData @0x00491330 — written unconditionally
// (lane A §4.5), no HasProfile gate; a zeroed/empty snapshot degrades to
// an empty name and zero counters naturally, not a special case.
private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot)
{
SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor);
SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor);
SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor);
}
// gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc.
private void RefreshMonarchBlock(
RuntimeAllegianceSnapshot snapshot,
RuntimeAllegianceMemberSnapshot? monarch,
RuntimeAllegianceMemberSnapshot? patron)
{
bool hasMonarch = monarch is { } m && m.CharacterId != _bindings.LocalPlayerGuid();
_monarchField.Visible = hasMonarch;
if (!hasMonarch)
{
SetProvider(_monarchName, ref _lastMonarchName, BlankSentinel, BlankLineProvider);
SetProvider(_monarchFollowers, ref _lastMonarchFollowers, BlankSentinel, BlankLineProvider);
if (_monarchIsPatronSubBlock is not null) _monarchIsPatronSubBlock.Visible = false;
return;
}
RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value;
SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor);
SetLine(
_monarchFollowers,
ref _lastMonarchFollowers,
(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(),
TextColor);
_monarchField.Enabled = monarchData.IsLoggedIn;
bool patronIsMonarch = patron is { } p && p.CharacterId == monarchData.CharacterId;
string? label = patronIsMonarch ? _patronSlashMonarchLabelCaption : _monarchLabelCaption;
if (_monarchLabel is not null && label is not null)
_monarchLabel.LinesProvider = () => [new UiText.Line(label, TextColor)];
if (_monarchIsPatronSubBlock is not null)
_monarchIsPatronSubBlock.Visible = patronIsMonarch;
if (patronIsMonarch)
{
uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u;
SetLine(_monarchExperiencePassedUp, ref _lastMonarchExperiencePassedUp, tithed.ToString(), TextColor);
}
}
// gmAllegianceUI::UpdatePatronData @0x004917C0 — see class doc.
private void RefreshPatronBlock(
RuntimeAllegianceSnapshot snapshot,
RuntimeAllegianceMemberSnapshot? monarch,
RuntimeAllegianceMemberSnapshot? patron)
{
bool hasPatron = patron is { } p
&& (monarch is not { } m || p.CharacterId != m.CharacterId);
_patronField.Visible = hasPatron;
if (!hasPatron)
{
SetProvider(_patronName, ref _lastPatronName, BlankSentinel, BlankLineProvider);
return;
}
RuntimeAllegianceMemberSnapshot patronData = patron!.Value;
SetLine(_patronName, ref _lastPatronName, patronData.Name, TextColor);
_patronField.Enabled = patronData.IsLoggedIn;
uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u;
SetLine(_patronExperiencePassedUp, ref _lastPatronExperiencePassedUp, tithed.ToString(), TextColor);
}
private void RefreshCheckboxSelection()
{
if (_ignoreRequestsCheckbox is null) return;
_ignoreRequestsCheckbox.Selected =
_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests);
}
// gmAllegianceUI::UpdateSwearButton/UpdateBreakButton (lane A §4.4) + the
// Kick enable check inlined at the end of gmAllegianceUI::Update.
private void RefreshButtonStates(
RuntimeAllegianceSnapshot snapshot,
uint selfGuid,
RuntimeAllegianceMemberSnapshot? patron)
{
if (_swearButton is not null)
{
uint? targetGuid = _bindings.Selection.SelectedObjectId;
// AD-84: retail additionally requires the target to be a player
// (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
// classification for this, same superset-of-retail limitation
// AD-83 already accepted for the Fellowship page's Recruit
// button. The server refuses a non-player target the same way
// retail's own disabled button would have silently no-op'd.
bool targetValid = targetGuid is { } id
&& id != selfGuid
&& _bindings.Member(id) is null;
_swearButton.Enabled = patron is null && targetValid;
}
if (_breakButton is not null)
_breakButton.Enabled = patron is not null;
if (_kickButton is not null)
_kickButton.Enabled = _selectedVassalGuid != 0u;
}
/// <summary>
/// Diffs the live vassal GUID set against the roster — unchanged set
/// updates rows in place; a real join/leave/kick rebuilds via
/// <see cref="UiTemplateListBox.FlushPreservingScroll"/> (the FA3/FA4
/// carry-forward — do NOT reintroduce the FA3 <c>Flush()</c> scroll
/// reset here).
/// </summary>
private void RefreshRoster(uint selfGuid)
{
if (_vassalListBox is null) return;
var vassals = new List<RuntimeAllegianceMemberSnapshot>(_bindings.Vassals(selfGuid));
bool membershipChanged = vassals.Count != _vassalGuids.Count;
if (!membershipChanged)
{
foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
{
if (_vassalGuids.Contains(vassal.CharacterId)) continue;
membershipChanged = true;
break;
}
}
if (membershipChanged)
RebuildRoster(vassals);
else
foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
UpdateRow(vassal);
if (_selectedVassalGuid != 0u && !_vassalGuids.Contains(_selectedVassalGuid))
_selectedVassalGuid = 0u;
}
private void RebuildRoster(List<RuntimeAllegianceMemberSnapshot> vassals)
{
_vassalListBox!.FlushPreservingScroll();
_rows.Clear();
_vassalGuids.Clear();
foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
_vassalGuids.Add(vassal.CharacterId);
foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
{
UiElement? row = _vassalListBox.AddItemFromTemplateList(0);
if (row is null)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: vassal row template did not "
+ $"build for guid 0x{vassal.CharacterId:X8}.");
continue;
}
var widgets = new VassalRowWidgets(
UiElement.FindDescendant(row, RowNameTextId) as UiText,
UiElement.FindDescendant(row, RowExperiencePassedUpTextId) as UiText,
UiElement.FindDescendant(row, RowOfflineMarkerId));
_rows[vassal.CharacterId] = widgets;
// AD-82 addendum (SF-9): the SAME page-local "no generic
// per-row-element click primitive" limitation FA4 already
// registered — only the row's name text is a click target.
if (widgets.Name is { } nameText)
{
uint guid = vassal.CharacterId;
nameText.OnClick = () => SelectVassal(guid);
}
}
foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
UpdateRow(vassal);
}
private void UpdateRow(RuntimeAllegianceMemberSnapshot vassal)
{
if (!_rows.TryGetValue(vassal.CharacterId, out VassalRowWidgets widgets)) return;
if (widgets.Name is { } nameText)
{
string name = vassal.Name;
Vector4 color = vassal.IsLoggedIn ? TextColor : OfflineNameColor;
nameText.LinesProvider = () => [new UiText.Line(name, color)];
}
if (widgets.ExperiencePassedUp is { } xpText)
{
string tithed = vassal.CpTithed.ToString();
xpText.LinesProvider = () => [new UiText.Line(tithed, TextColor)];
}
// 0x100004AA — SetVisible(1) when !IsLoggedIn, SetVisible(0) when
// logged in (ghidra@0x00492340, lane A §3.2).
if (widgets.OfflineMarker is not null)
widgets.OfflineMarker.Visible = !vassal.IsLoggedIn;
}
private void SelectVassal(uint guid)
{
if (_selectedVassalGuid == guid) return;
_selectedVassalGuid = guid;
}
/// <summary>
/// CF-1's visible-branch arm — edge-triggered exactly like FA4's
/// <c>SocialFellowshipPageController.SetPageVisible</c>. Called by
/// <see cref="SocialPanelController"/> on every "window shown AND
/// Allegiance active" transition, and once from <see cref="Bind"/> as
/// the PostInit attempt (see class doc).
/// </summary>
public void SetPageVisible(bool visible)
{
if (_subscribed == visible) return;
if (_bindings.SetUpdateSubscription(visible).Status == RuntimeCommandStatus.Accepted)
_subscribed = visible;
}
/// <summary>
/// Pre-world generation-reset seam — clears the latch WITHOUT sending
/// anything, mirroring <c>SocialFellowshipPageController.ResetPageVisibleLatch</c>.
/// </summary>
public void ResetPageVisibleLatch() => _subscribed = false;
/// <summary>
/// CF-1's PlayerDescReceived arm — the post-world <c>EnteredWorld</c>
/// seam. UNCONDITIONAL (does not compare against the current latch
/// first): see the class doc's "FA4 MF-3-REOPEN lesson, applied here"
/// section for why this must not be edge-triggered like
/// <see cref="SetPageVisible"/>.
/// </summary>
public void RedeclareAfterWorldEntry()
{
if (_bindings.SetUpdateSubscription(true).Status == RuntimeCommandStatus.Accepted)
_subscribed = true;
}
private static void SetLine(UiText? text, ref string? lastValue, string newValue, Vector4 color)
{
if (text is null) return;
if (lastValue == newValue) return;
lastValue = newValue;
text.LinesProvider = () => [new UiText.Line(newValue, color)];
}
private static void SetProvider(
UiText? text,
ref string? lastValue,
string? sentinelValue,
Func<IReadOnlyList<UiText.Line>> provider)
{
if (text is null) return;
if (lastValue == sentinelValue) return;
lastValue = sentinelValue;
text.LinesProvider = provider;
}
}

View file

@ -91,11 +91,15 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <c>FellowshipSnapshot</c> field with the page's full read/write seam
/// (roster enumeration, the seven commands, selection, character
/// options, string resolution — see
/// <see cref="SocialFellowshipPageController.Bindings"/>'s own doc).</summary>
/// <see cref="SocialFellowshipPageController.Bindings"/>'s own doc).
/// Campaign FA slice FA5: <see cref="Allegiance"/> is the same
/// widening for the Allegiance page, replacing FA3's bare
/// <c>AllegianceSnapshot</c> field — see
/// <see cref="SocialAllegiancePageController.Bindings"/>'s own doc.</summary>
public sealed record Callbacks(
Action Toggle,
SocialFellowshipPageController.Bindings Fellowship,
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
SocialAllegiancePageController.Bindings Allegiance,
FriendsState Friends,
SquelchState Squelch,
Func<uint, uint, UiElement?> TemplateResolver);
@ -148,7 +152,15 @@ public sealed class SocialPanelController : IRetainedPanelController
// very first one ActivateTabBehavior's default-entry activation
// fires. Fix-round SF-4: stored as a field (not an inline lambda)
// so Dispose can unsubscribe it.
_onActivePageChanged = (_, _) => UpdateFellowshipPageVisibility();
// Campaign FA slice FA5: the Allegiance page's CF-1 subscription
// (0x001F) has the identical "window shown AND MY tab active"
// conjunction as Fellowship's 0x00A6 — folded into the same event
// subscription rather than a second one.
_onActivePageChanged = (_, _) =>
{
UpdateFellowshipPageVisibility();
UpdateAllegiancePageVisibility();
};
_tabPanel.ActivePageChanged += _onActivePageChanged;
}
@ -187,7 +199,7 @@ public sealed class SocialPanelController : IRetainedPanelController
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
SocialAllegiancePageController? allegiance = allegiancePage is null
? null
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance);
SocialFriendsPageController? friends = friendsPage is null
? null
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver);
@ -263,6 +275,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{
_visible = true;
UpdateFellowshipPageVisibility();
UpdateAllegiancePageVisibility();
}
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
@ -271,6 +284,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{
_visible = false;
UpdateFellowshipPageVisibility();
UpdateAllegiancePageVisibility();
}
/// <summary>D4: the Fellowship page is "visible" (and therefore
@ -279,6 +293,14 @@ public sealed class SocialPanelController : IRetainedPanelController
private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
/// <summary>CF-1's visible-branch analogue for the Allegiance page: the
/// social WINDOW is shown AND Allegiance is the active tab. Allegiance
/// is the authored DEFAULT tab, so this is true immediately after
/// <see cref="ActivateTabs"/> — gated on <see cref="_visible"/>
/// (initially false) the same way Fellowship's conjunction is.</summary>
private void UpdateAllegiancePageVisibility() =>
_allegiance?.SetPageVisible(_visible && IsShowingAllegiance);
/// <summary>
/// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called
/// from <see cref="RetailUiRuntime.ResetSessionTransientUi"/>, a seam
@ -291,8 +313,14 @@ public sealed class SocialPanelController : IRetainedPanelController
/// dropped (Inactive). The actual re-declaration is
/// <see cref="RedeclareAfterWorldEntry"/>, wired to the post-world
/// <c>EnteredWorld</c> seam (FA4 re-review REOPEN, 2026-08-12).
/// Campaign FA slice FA5: also clears the Allegiance page's CF-1
/// <c>0x001F</c> latch the identical way.
/// </summary>
public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch();
public void ResetSessionDeclaration()
{
_fellowship?.ResetPageVisibleLatch();
_allegiance?.ResetPageVisibleLatch();
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the
@ -306,8 +334,26 @@ public sealed class SocialPanelController : IRetainedPanelController
/// default). Idempotent — if a persisted layout already re-showed the
/// page and <see cref="OnShown"/> re-declared, the latch is already set
/// and this is a no-op.
///
/// <para>
/// Campaign FA slice FA5: also re-arms the Allegiance page's <c>0x001F</c>
/// subscription — but via <c>SocialAllegiancePageController.
/// RedeclareAfterWorldEntry</c>, NOT the conjunction-gated
/// <c>UpdateAllegiancePageVisibility</c>: CF-1's PlayerDescReceived arm
/// is UNCONDITIONAL in retail (§1.2 — it always sends <c>1</c>
/// regardless of panel visibility), unlike Fellowship's <c>0x00A6</c>,
/// which retail only ever declares from the visibility toggle. Calling
/// the conjunction-gated method here instead would repeat the EXACT
/// MF-3-REOPEN class of bug this method's own history warns about — a
/// closed-panel reconnect would never re-subscribe and the allegiance
/// data would silently stay stale for the rest of the session.
/// </para>
/// </summary>
public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility();
public void RedeclareAfterWorldEntry()
{
UpdateFellowshipPageVisibility();
_allegiance?.RedeclareAfterWorldEntry();
}
/// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no

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);