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), isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen), panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
d.Actions.Selection, 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, StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry, Plugins: d.UiRegistry,
Persistence: persistence, Persistence: persistence,

View file

@ -158,6 +158,29 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen( Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
generation, panelOpen)); 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() public void Deactivate()
{ {
lock (_gate) lock (_gate)

View file

@ -1,137 +1,782 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Runtime; using AcDream.Runtime;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
/// <summary> /// <summary>
/// Campaign FA slice FA3: the Allegiance page's empty-state presentation — /// Campaign FA slice FA5: the Allegiance page fully live — the <c>0x001F</c>
/// the ONLY behavior this SHELL slice owns for this page (live monarch/ /// data subscription (CF-1), monarch/patron/self blocks, the flat vassal
/// patron/vassal population, swear/break/kick + confirmations, and the /// list, swear/break/kick + their local confirmation dialogs, and the
/// per-member online-state dimming are FA5 scope). /// 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> /// <para>
/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it /// <b>CF-1 — the data subscription.</b>
/// hides the monarch/patron blocks and blanks their name text to a literal /// <c>docs/research/2026-08-11-fa-allegiance-wire.md</c> §1.2 pins THREE
/// space per-block, gated on whether each relationship exists /// retail arming points for <c>CM_Allegiance::Event_UpdateRequest(u32)</c>
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract /// (opcode <c>0x001F</c>), all sending <c>1</c> except the last:
/// simplifies this to the single gate this campaign's Runtime owner /// <c>gmAllegianceUI::PostInit @0x004911C6</c>, <c>RecvNotice_PlayerDescReceived
/// actually exposes today: <see cref="RuntimeAllegianceSnapshot.HasProfile"/> /// @0x00490D40</c>, and <c>OnVisibilityChanged</c>'s visible/hidden branches
/// — no allegiance push has landed this generation. When a profile HAS /// (<c>@0x004912DD</c>/<c>@0x00491311</c>). <see cref="Bind"/> attempts the
/// arrived, this controller leaves both blocks visible with their /// PostInit arm (almost always a pre-world no-op in this campaign's
/// build-time (empty) text rather than fabricate monarch/patron content — /// process-lifetime mount — see <see cref="RedeclareAfterWorldEntry"/>'s own
/// that population is FA5's job. /// 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>
/// ///
/// <para> /// <para>
/// Element ids confirmed by the FA3 live-mount probe: /// <b>The FA4 MF-3-REOPEN lesson, applied here.</b> A reconnect's generation
/// <c>0x10000255</c> monarch-field container, <c>0x1000025A</c> /// reset runs BEFORE the new session is in world, where every Runtime
/// patron-field container, <c>0x10000257</c> monarch-name text (inside the /// command is world-gated (<c>Inactive</c>, nothing sent).
/// monarch field), <c>0x1000025C</c> patron-name text (inside the patron /// <see cref="ResetPageVisibleLatch"/> (wired to the pre-world reset seam)
/// field). Both lookups are SCOPED to the allegiance page root — the panel /// ONLY clears the local latch — it must never itself declare.
/// also authors <c>0x10000492</c> (the XP-passed-up text) TWICE, once under /// <see cref="RedeclareAfterWorldEntry"/> (wired to the POST-world seam)
/// each block, so a flat lookup anywhere in this subsystem would risk /// does the actual send, and — unlike <see cref="SetPageVisible"/> — is
/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP /// UNCONDITIONAL: it does not compare against the current latch first,
/// <c>0x10000211</c>-in-two-layouts lesson). /// 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>
/// ///
/// <para> /// <para>
/// <b>FA5 scope note (fix-round mechanism SF-7).</b> This shell's gate is /// <b>Per-relationship empty-state gate (fix-round SF-7).</b>
/// coarser than retail's: <c>gmAllegianceUI::UpdateMonarchData /// <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c> hides the monarch
/// @0x00491B40</c> hides the monarch block when there is no monarch OR the /// block when there is no monarch OR the monarch IS the viewer;
/// monarch IS the viewer, and hides the patron block on the analogous /// <c>UpdatePatronData @0x004917C0</c> hides the patron block when there is
/// per-relationship test — this controller instead gates BOTH blocks on /// no patron OR the patron IS the monarch (in which case the monarch block
/// the single <see cref="RuntimeAllegianceSnapshot.HasProfile"/> flag. The /// relabels via <c>ID_Allegiance_PatronSlashMonarchLabel</c> and reveals its
/// plan's FA5 row records the two per-relationship acceptance lines FA5 /// <c>0x10000490</c> sub-block, byte-verified at the same two functions).
/// owes. Also note for whoever lands FA5: <see cref="Tick"/> reassigns /// Both hidden branches blank their name text to a literal single space —
/// <see cref="UiText.LinesProvider"/> UNCONDITIONALLY every frame — FA5's /// <c>UpdateMonarchData</c> ALSO blanks the monarch-followers text in its
/// real monarch/patron name population must change this method at the /// hidden branch (<c>@0x00491FCD</c>), a detail the coarser FA3 gate did not
/// same time, or its content will be overwritten on the very next frame. /// 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> /// </para>
/// </summary> /// </summary>
public sealed class SocialAllegiancePageController 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 = private static readonly IReadOnlyList<UiText.Line> BlankLine =
[new UiText.Line(" ", System.Numerics.Vector4.One)]; [new UiText.Line(" ", TextColor)];
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>
private static readonly Func<IReadOnlyList<UiText.Line>> BlankLineProvider = () => BlankLine; 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 _monarchField;
private readonly UiElement _patronField; private readonly UiText? _monarchLabel;
private readonly UiText? _monarchName; 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 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( private SocialAllegiancePageController(
Bindings bindings,
UiText? selfName,
UiText? selfFollowers,
UiText? selfRank,
UiElement monarchField, UiElement monarchField,
UiElement patronField, UiText? monarchLabel,
UiText? monarchName, UiText? monarchName,
UiText? monarchFollowers,
UiElement? monarchIsPatronSubBlock,
UiText? monarchExperiencePassedUp,
UiElement patronField,
UiText? patronName, 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; _monarchField = monarchField;
_patronField = patronField; _monarchLabel = monarchLabel;
_monarchName = monarchName; _monarchName = monarchName;
_monarchFollowers = monarchFollowers;
_monarchIsPatronSubBlock = monarchIsPatronSubBlock;
_monarchExperiencePassedUp = monarchExperiencePassedUp;
_patronField = patronField;
_patronName = patronName; _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( public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
UiElement pageRoot,
Func<RuntimeAllegianceSnapshot> snapshot)
{ {
ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot); ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField) || UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField)
{ {
Console.WriteLine( Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field " "[D.2b] SocialAllegiancePageController: monarch/patron field "
+ "containers (0x10000255/0x1000025A) not found — allegiance page " + $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — "
+ "will not present its empty state."); + "allegiance page will not present.");
return null; return null;
} }
// Scoped to each block — 0x10000257/0x1000025C are unique per-block, // Scoped lookups — see class doc + §6 DISCIPLINE. 0x10000492 is
// but the lookup discipline matters equally here (see class doc). // authored TWICE; each instance is resolved under its OWN parent.
UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText; UiElement? monarchIsPatronSubBlock =
UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText; UiElement.FindDescendant(monarchField, MonarchIsPatronSubBlockId);
if (monarchName is null) UiText? monarchExperiencePassedUp = monarchIsPatronSubBlock is null
Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found."); ? null
if (patronName is null) : UiElement.FindDescendant(monarchIsPatronSubBlock, ExperiencePassedUpTextId) as UiText;
Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found."); 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( 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(); 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; 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> /// <summary>
/// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap /// <c>gmAllegianceUI::UpdateSwearButton @0x004908E0</c>'s dialog half —
/// — called every frame from <see cref="SocialPanelController.Tick"/> (same /// the TARGET is latched here, at click time (retail's own
/// reasoning as <see cref="SocialFellowshipPageController.Tick"/>). /// <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> /// </summary>
public void Tick() public void Tick()
{ {
bool hasProfile = _snapshot().HasProfile; RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot();
_monarchField.Visible = hasProfile; uint selfGuid = _bindings.LocalPlayerGuid();
_patronField.Visible = hasProfile; RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null;
RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null;
Func<IReadOnlyList<UiText.Line>> provider = hasProfile ? NoLinesProvider : BlankLineProvider; RefreshSelfBlock(snapshot);
if (_monarchName is not null) _monarchName.LinesProvider = provider; RefreshMonarchBlock(snapshot, monarch, patron);
if (_patronName is not null) _patronName.LinesProvider = provider; 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 /// <c>FellowshipSnapshot</c> field with the page's full read/write seam
/// (roster enumeration, the seven commands, selection, character /// (roster enumeration, the seven commands, selection, character
/// options, string resolution — see /// 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( public sealed record Callbacks(
Action Toggle, Action Toggle,
SocialFellowshipPageController.Bindings Fellowship, SocialFellowshipPageController.Bindings Fellowship,
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot, SocialAllegiancePageController.Bindings Allegiance,
FriendsState Friends, FriendsState Friends,
SquelchState Squelch, SquelchState Squelch,
Func<uint, uint, UiElement?> TemplateResolver); Func<uint, uint, UiElement?> TemplateResolver);
@ -148,7 +152,15 @@ public sealed class SocialPanelController : IRetainedPanelController
// very first one ActivateTabBehavior's default-entry activation // very first one ActivateTabBehavior's default-entry activation
// fires. Fix-round SF-4: stored as a field (not an inline lambda) // fires. Fix-round SF-4: stored as a field (not an inline lambda)
// so Dispose can unsubscribe it. // 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; _tabPanel.ActivePageChanged += _onActivePageChanged;
} }
@ -187,7 +199,7 @@ public sealed class SocialPanelController : IRetainedPanelController
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship); : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
SocialAllegiancePageController? allegiance = allegiancePage is null SocialAllegiancePageController? allegiance = allegiancePage is null
? null ? null
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot); : SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance);
SocialFriendsPageController? friends = friendsPage is null SocialFriendsPageController? friends = friendsPage is null
? null ? null
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver); : SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver);
@ -263,6 +275,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{ {
_visible = true; _visible = true;
UpdateFellowshipPageVisibility(); UpdateFellowshipPageVisibility();
UpdateAllegiancePageVisibility();
} }
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every /// <summary><see cref="IRetainedPanelController"/> hook, fired on every
@ -271,6 +284,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{ {
_visible = false; _visible = false;
UpdateFellowshipPageVisibility(); UpdateFellowshipPageVisibility();
UpdateAllegiancePageVisibility();
} }
/// <summary>D4: the Fellowship page is "visible" (and therefore /// <summary>D4: the Fellowship page is "visible" (and therefore
@ -279,6 +293,14 @@ public sealed class SocialPanelController : IRetainedPanelController
private void UpdateFellowshipPageVisibility() => private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship); _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> /// <summary>
/// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called /// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called
/// from <see cref="RetailUiRuntime.ResetSessionTransientUi"/>, a seam /// from <see cref="RetailUiRuntime.ResetSessionTransientUi"/>, a seam
@ -291,8 +313,14 @@ public sealed class SocialPanelController : IRetainedPanelController
/// dropped (Inactive). The actual re-declaration is /// dropped (Inactive). The actual re-declaration is
/// <see cref="RedeclareAfterWorldEntry"/>, wired to the post-world /// <see cref="RedeclareAfterWorldEntry"/>, wired to the post-world
/// <c>EnteredWorld</c> seam (FA4 re-review REOPEN, 2026-08-12). /// <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> /// </summary>
public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch(); public void ResetSessionDeclaration()
{
_fellowship?.ResetPageVisibleLatch();
_allegiance?.ResetPageVisibleLatch();
}
/// <summary> /// <summary>
/// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the /// 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 /// default). Idempotent — if a persisted layout already re-showed the
/// page and <see cref="OnShown"/> re-declared, the latch is already set /// page and <see cref="OnShown"/> re-declared, the latch is already set
/// and this is a no-op. /// 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> /// </summary>
public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility(); public void RedeclareAfterWorldEntry()
{
UpdateFellowshipPageVisibility();
_allegiance?.RedeclareAfterWorldEntry();
}
/// <summary> /// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no /// 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 /// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection). /// currently-selected WORLD object, not a panel-local selection).
/// </para> /// </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> /// </summary>
public sealed record SocialRuntimeBindings( public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot, Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
@ -261,7 +275,15 @@ public sealed record SocialRuntimeBindings(
Func<bool, RuntimeCommandResult> FellowshipSetOpen, Func<bool, RuntimeCommandResult> FellowshipSetOpen,
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen, Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
SelectionState Selection, 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( public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
@ -2773,7 +2795,30 @@ public sealed class RetailUiRuntime : IDisposable
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish( SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)), new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)), 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, Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch, Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver); TemplateResolver: TemplateResolver);

View file

@ -72,6 +72,51 @@ public sealed class GameplayConfirmationControllerTests
Assert.Equal(0u, controller.ActiveDialogContext); Assert.Equal(0u, controller.ActiveDialogContext);
} }
/// <summary>
/// Campaign FA slice FA5, item 3: verifies the allegiance-swear
/// confirmation (<c>ConfirmationType.AllegianceSwear</c>, type 1 —
/// <c>RecvNotice_SwearAllegianceRequest</c> ->
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s case 1)
/// reaches this SAME generic controller, mirroring the type-4 test
/// above exactly — no allegiance-specific intercept exists (there
/// never was one to remove; D6's fellowship correction did not touch
/// type 1 at all, but FA5's own contract calls for this explicit check
/// since <c>SocialAllegiancePageController</c> is the new panel that
/// makes this path reachable). Type 1 is NOT in the 2/3/5/6 " Continue?"
/// suffix set, so the message renders verbatim — ACE's own type-1
/// message is the target's BARE name (lane C §6.4:
/// <c>Player_Allegiance.cs:91</c>/<c>ConfirmationManager.cs:38</c>), not
/// a full sentence, which this test's message deliberately mirrors
/// rather than inventing retail's unported <c>StringInfo</c>-wrapped
/// sentence (AD-85).
/// </summary>
[Fact]
public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
{
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
Assert.True(controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(1u, 13u, "Bob")));
Assert.Equal(
"Bob",
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([(1u, 13u, true)], responses);
Assert.Equal(0u, controller.ActiveDialogContext);
}
[Fact] [Fact]
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing() public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
{ {

View file

@ -63,19 +63,66 @@ public sealed class SocialPanelControllerTests
ResolveString: resolveString ?? ((_, _) => null)); ResolveString: resolveString ?? ((_, _) => null));
} }
/// <summary>Campaign FA slice FA5: the Allegiance page's full read/write
/// seam, mirroring <see cref="MakeFellowshipBindings"/>'s recorded-call
/// shape. Every accessor defaults to "empty" (no monarch/patron/member/
/// vassals) so a test that only cares about ONE relationship can pass a
/// bare <paramref name="snapshot"/> without wiring the rest.</summary>
private static SocialAllegiancePageController.Bindings MakeAllegianceBindings(
List<string>? calls = null,
RuntimeAllegianceSnapshot snapshot = default,
RuntimeAllegianceMemberSnapshot? monarch = null,
Func<uint, RuntimeAllegianceMemberSnapshot?>? patron = null,
Func<uint, RuntimeAllegianceMemberSnapshot?>? member = null,
Func<uint, IEnumerable<RuntimeAllegianceMemberSnapshot>>? vassals = null,
AcDream.Core.Selection.SelectionState? selection = null,
uint localPlayerGuid = 0u,
Func<CharacterOptionId, bool>? currentCharacterOption = null,
Func<uint, uint, UiElement?>? templateResolver = null,
Func<uint, uint, string?>? resolveString = null,
Func<uint, string?>? resolveWorldObjectName = null,
Func<string, Action<bool>, uint>? showConfirmation = null)
{
calls ??= new List<string>();
return new SocialAllegiancePageController.Bindings(
Snapshot: () => snapshot,
Monarch: () => monarch,
Patron: patron ?? (_ => null),
Member: member ?? (_ => null),
Vassals: vassals ?? (_ => []),
Swear: guid => { calls.Add($"allegiance-swear:{guid:X8}"); return InactiveResult; },
Break: guid => { calls.Add($"allegiance-break:{guid:X8}"); return InactiveResult; },
Kick: guid => { calls.Add($"allegiance-kick:{guid:X8}"); return InactiveResult; },
// Defaults Accepted (in-world) like MakeFellowshipBindings' own
// SetPanelOpen default — CF-1's edge-trigger latch only advances
// on Accepted, so a test exercising OnShown->OnHidden needs the
// send to actually "take" for the second transition to register
// as a real change.
SetUpdateSubscription: on => { calls.Add($"allegiance-set-subscription:{on}"); return AcceptedResult; },
Selection: selection ?? new AcDream.Core.Selection.SelectionState(),
LocalPlayerGuid: () => localPlayerGuid,
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
SetCharacterOption: (id, value) => calls.Add($"allegiance-set-option:{id}:{value}"),
TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
ResolveString: resolveString ?? ((_, _) => null),
ResolveWorldObjectName: resolveWorldObjectName ?? (_ => null),
ShowConfirmation: showConfirmation ?? ((_, _) => 0u));
}
private static SocialPanelController.Callbacks MakeCallbacks( private static SocialPanelController.Callbacks MakeCallbacks(
List<string>? calls = null, List<string>? calls = null,
RuntimeFellowshipSnapshot fellowship = default, RuntimeFellowshipSnapshot fellowship = default,
RuntimeAllegianceSnapshot allegiance = default, RuntimeAllegianceSnapshot allegiance = default,
FriendsState? friends = null, FriendsState? friends = null,
SquelchState? squelch = null, SquelchState? squelch = null,
Func<bool>? panelOpenInWorld = null) Func<bool>? panelOpenInWorld = null,
SocialAllegiancePageController.Bindings? allegianceBindings = null)
{ {
calls ??= new List<string>(); calls ??= new List<string>();
return new SocialPanelController.Callbacks( return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"), Toggle: () => calls.Add("toggle"),
Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld), Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld),
AllegianceSnapshot: () => allegiance, Allegiance: allegianceBindings ?? MakeAllegianceBindings(calls, allegiance),
Friends: friends ?? new FriendsState(), Friends: friends ?? new FriendsState(),
Squelch: squelch ?? new SquelchState(), Squelch: squelch ?? new SquelchState(),
TemplateResolver: FakeRowTemplateResolver); TemplateResolver: FakeRowTemplateResolver);
@ -205,7 +252,7 @@ public sealed class SocialPanelControllerTests
Assert.True(inFellowship.Visible); Assert.True(inFellowship.Visible);
} }
// ── Allegiance empty state (item 4) ──────────────────────────────────── // ── Allegiance empty state + per-relationship gate (FA5, fix-round SF-7)
[Fact] [Fact]
public void Allegiance_NoProfile_HidesBlocksAndBlanksNames() public void Allegiance_NoProfile_HidesBlocksAndBlanksNames()
@ -229,19 +276,335 @@ public sealed class SocialPanelControllerTests
Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text); Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text);
} }
/// <summary>
/// Fix-round SF-7: <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c>
/// hides the monarch block PER-RELATIONSHIP, not on the coarse FA3
/// <c>HasProfile</c> flag alone — a real monarch who is NOT the viewer
/// shows the block, with the name and <c>_total_members - 1</c> follower
/// count rendered.
/// </summary>
[Fact] [Fact]
public void Allegiance_HasProfile_ShowsBothBlocks() public void Allegiance_HasMonarch_NotSelf_ShowsMonarchBlock_AndRendersData()
{ {
const uint selfGuid = 100u;
const uint monarchGuid = 200u;
var monarch = new RuntimeAllegianceMemberSnapshot(
monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 3u },
monarch: monarch,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind( SocialPanelController? controller = SocialPanelController.Bind(
layout, layout, MakeCallbacks(allegianceBindings: bindings));
MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = true })); Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
Assert.True(monarchField.Visible);
var monarchName = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000257u));
Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text);
var monarchFollowers = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000258u));
Assert.Equal("2", Assert.Single(monarchFollowers.LinesProvider()).Text);
}
/// <summary>SF-7's own additional test — the monarch IS the viewer:
/// retail hides the block even though a monarch record exists.</summary>
[Fact]
public void Allegiance_IsMonarch_HidesMonarchBlock()
{
const uint selfGuid = 100u;
var monarch = new RuntimeAllegianceMemberSnapshot(
selfGuid, 0u, true, "Me", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
monarch: monarch,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
Assert.False(monarchField.Visible);
}
/// <summary>Patron analogue of the monarch test — a real patron who is
/// NOT the monarch shows the block with the patron's name.</summary>
[Fact]
public void Allegiance_HasPatron_NotMonarch_ShowsPatronBlock_AndRendersName()
{
const uint selfGuid = 100u;
const uint patronGuid = 300u;
var patron = new RuntimeAllegianceMemberSnapshot(
patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
patron: guid => guid == selfGuid ? patron : null,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement patronField = UiElement.FindDescendant(controller!.TabPanel, 0x1000025Au)!;
Assert.True(patronField.Visible);
var patronName = Assert.IsType<UiText>(UiElement.FindDescendant(patronField, 0x1000025Cu));
Assert.Equal("Sir Bob", Assert.Single(patronName.LinesProvider()).Text);
}
/// <summary>
/// SF-7's headline case: patron IS the monarch — the patron block hides
/// AND the monarch block's <c>0x10000490</c> sub-block (the XP-passed-up
/// line) reveals, matching <c>UpdateMonarchData</c>'s
/// <c>PatronSlashMonarchLabel</c> branch.
/// </summary>
[Fact]
public void Allegiance_PatronIsMonarch_HidesPatronBlock_RevealsMonarchSubBlock()
{
const uint selfGuid = 100u;
const uint monarchGuid = 200u;
var monarch = new RuntimeAllegianceMemberSnapshot(
monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 2u },
monarch: monarch,
patron: guid => guid == selfGuid ? monarch : null,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller); Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!; UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!; UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
Assert.True(monarchField.Visible); Assert.True(monarchField.Visible);
Assert.True(patronField.Visible); Assert.False(patronField.Visible);
UiElement subBlock = UiElement.FindDescendant(monarchField, 0x10000490u)!;
Assert.True(subBlock.Visible);
}
/// <summary>The vassal list populates one row per vassal, in the
/// bindings' own (already-reversed) order — the controller must not
/// re-sort.</summary>
[Fact]
public void Allegiance_VassalList_PopulatesOneRowPerVassal()
{
const uint selfGuid = 100u;
var vassals = new List<RuntimeAllegianceMemberSnapshot>
{
new(401u, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 10u, 0, 0, true),
new(402u, selfGuid, false, "Vassal Two", 0, 0, 0, 0, 0u, 20u, 0, 0, true),
};
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
vassals: guid => guid == selfGuid ? vassals : [],
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(allegiancePage, 0x10000260u));
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
/// <summary>Swear latches the WORLD selection at click time and, on
/// accept, sends the swear command for that exact guid.</summary>
[Fact]
public void Allegiance_Swear_ShowsConfirmation_AndSendsOnAccept()
{
const uint selfGuid = 100u;
const uint targetGuid = 500u;
var calls = new List<string>();
Action<bool>? capturedCallback = null;
var selection = new AcDream.Core.Selection.SelectionState();
selection.Select(targetGuid, AcDream.Core.Selection.SelectionChangeSource.World);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
selection: selection,
localPlayerGuid: selfGuid,
resolveWorldObjectName: guid => guid == targetGuid ? "Target Player" : null,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var swearButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000263u));
swearButton.OnClick!();
Assert.Contains("confirm:Target Player", calls);
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-swear:{targetGuid:X8}", calls);
}
/// <summary>Break targets the CURRENT patron (latched at click time),
/// not the world/panel selection.</summary>
[Fact]
public void Allegiance_Break_TargetsCurrentPatron()
{
const uint selfGuid = 100u;
const uint patronGuid = 300u;
var patron = new RuntimeAllegianceMemberSnapshot(
patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
var calls = new List<string>();
Action<bool>? capturedCallback = null;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
patron: guid => guid == selfGuid ? patron : null,
localPlayerGuid: selfGuid,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var breakButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000264u));
breakButton.OnClick!();
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-break:{patronGuid:X8}", calls);
}
/// <summary>Kick targets the panel-local SELECTED VASSAL ROW, not the
/// world selection (lane A §6.2 — Allegiance's list-selection message
/// has no SetSelectedObject call, unlike Fellowship's).</summary>
[Fact]
public void Allegiance_Kick_TargetsSelectedVassalRow()
{
const uint selfGuid = 100u;
const uint vassalGuid = 401u;
var vassal = new RuntimeAllegianceMemberSnapshot(
vassalGuid, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 0u, 0, 0, true);
var calls = new List<string>();
Action<bool>? capturedCallback = null;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
// The shared fixture-page FakeRowTemplateResolver (untagged, sized
// for Friends/Squelch's two-deep text nesting) doesn't tag its
// built row with the vassal row's own element ids — a real click
// target needs 0x10000268 resolvable, so this test supplies its own
// tagged resolver (mirrors SocialFellowshipPageControllerTests'
// hand-built row shape).
UiElement? TaggedVassalRowResolver(uint layoutId, uint elementId)
{
var row = new UiPanel();
var name = new UiText();
name.DatElementId = 0x10000268u;
row.AddChild(name);
return row;
}
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
vassals: guid => guid == selfGuid ? [vassal] : [],
member: guid => guid == vassalGuid ? vassal : null,
localPlayerGuid: selfGuid,
templateResolver: TaggedVassalRowResolver,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(allegiancePage, 0x10000260u));
var kickButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000265u));
Assert.False(kickButton.Enabled); // nothing selected yet
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
var rowName = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000268u));
rowName.OnClick!();
controller!.Tick();
Assert.True(kickButton.Enabled);
kickButton.OnClick!();
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-kick:{vassalGuid:X8}", calls);
}
// ── CF-1: 0x001F subscription arming points ─────────────────────────────
/// <summary>CF-1's visible-branch arm, mirroring
/// <c>FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive</c>.
/// Allegiance is the DEFAULT tab, so the conjunction is already true the
/// instant the window is shown (no tab switch needed).</summary>
[Fact]
public void AllegiancePageVisible_DeclaresSubscription_OnWindowShown_AndClearsOnHidden()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls));
Assert.NotNull(controller);
controller!.ActivateTabs();
calls.Clear();
Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
controller.OnShown();
Assert.Contains("allegiance-set-subscription:True", calls);
calls.Clear();
controller.OnHidden();
Assert.Contains("allegiance-set-subscription:False", calls);
}
/// <summary>
/// The exact bug class MF-3-REOPEN caught for Fellowship's <c>0x00A6</c>,
/// re-verified here for Allegiance's <c>0x001F</c>: a reconnect must
/// re-subscribe from the POST-world seam, UNCONDITIONALLY — even while
/// the panel is closed, because retail's own <c>RecvNotice_PlayerDescReceived</c>
/// arm does not check panel visibility either (§1.2).
/// </summary>
[Fact]
public void Reconnect_ReDeclaresSubscription_AfterWorldEntry_EvenWhilePanelClosed()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls));
Assert.NotNull(controller);
controller!.ActivateTabs(); // Allegiance is the default tab, window still closed
calls.Clear();
controller.ResetSessionDeclaration();
Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
controller.RedeclareAfterWorldEntry();
Assert.Contains("allegiance-set-subscription:True", calls);
} }
// ── Friends/Squelch read-only lists (item 5) ──────────────────────────── // ── Friends/Squelch read-only lists (item 5) ────────────────────────────

View file

@ -276,6 +276,134 @@ public sealed class SocialPanelLiveMountProbeTests
// fail this test, not just the log. // fail this test, not just the log.
Assert.Equal(2, passupCount); Assert.Equal(2, passupCount);
// Campaign FA slice FA5, item 6: the DOUBLED 0x10000492 SCOPED
// resolution — proves SocialAllegiancePageController's two
// FindDescendant calls (one under 0x10000490, one under the patron
// field 0x1000025A) each resolve to a DIFFERENT real instance, not
// just that two instances exist somewhere in the subtree (the count
// check above). A flat/unscoped lookup would silently pick ONE
// instance for BOTH roles and this probe would not catch it.
UiElement? monarchField = UiElement.FindDescendant(tabs, 0x10000255u);
UiElement? patronField = UiElement.FindDescendant(tabs, 0x1000025Au);
Assert.NotNull(monarchField);
Assert.NotNull(patronField);
UiElement? monarchIsPatronSubBlock = UiElement.FindDescendant(monarchField!, 0x10000490u);
Console.WriteLine(
$"[socialprobe] allegiance sub-block 0x10000490 (under monarch field) -> "
+ $"{(monarchIsPatronSubBlock is null ? "MISSING" : monarchIsPatronSubBlock.GetType().Name)}");
Assert.NotNull(monarchIsPatronSubBlock);
UiElement? monarchScopedPassup = UiElement.FindDescendant(monarchIsPatronSubBlock!, 0x10000492u);
UiElement? patronScopedPassup = UiElement.FindDescendant(patronField!, 0x10000492u);
Console.WriteLine(
$"[socialprobe] 0x10000492 under 0x10000490 -> {(monarchScopedPassup is null ? "MISSING" : "0x" + monarchScopedPassup.DatElementId.ToString("X8"))}; "
+ $"under patron field -> {(patronScopedPassup is null ? "MISSING" : "0x" + patronScopedPassup.DatElementId.ToString("X8"))}");
Assert.NotNull(monarchScopedPassup);
Assert.NotNull(patronScopedPassup);
Assert.NotSame(monarchScopedPassup, patronScopedPassup);
// The vassal ListBox's own authored template pair and scrollbar
// (mirrors the fellowship row-template check above).
UiElement? vassalListBoxEl = UiElement.FindDescendant(tabs, 0x10000260u);
UiTemplateListBox vassalListBox = Assert.IsType<UiTemplateListBox>(vassalListBoxEl);
Console.WriteLine(
$"[socialprobe] allegiance ListBox 0x10000260 templates={vassalListBox.Templates.Count} "
+ $"scrollbar=0x{vassalListBox.ScrollbarElementId:X8}");
Assert.NotEmpty(vassalListBox.Templates);
UiTemplateListEntry vassalRowTemplate = vassalListBox.Templates[0];
UiElement? vassalRow = rowTemplates.Resolve(
vassalRowTemplate.TemplateLayoutId, vassalRowTemplate.TemplateElementId);
Console.WriteLine(
$"[socialprobe] allegiance row template 0x{vassalRowTemplate.TemplateLayoutId:X8}/"
+ $"0x{vassalRowTemplate.TemplateElementId:X8} -> {(vassalRow is null ? "IMPORT NULL" : vassalRow.GetType().Name)}");
Assert.NotNull(vassalRow);
foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
{
(0x10000268u, "VassalName", typeof(UiText)),
(0x10000269u, "VassalExperiencePassedUp", typeof(UiText)),
})
{
UiElement? el = UiElement.FindDescendant(vassalRow!, id);
Console.WriteLine($"[socialprobe] allegiance row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
Assert.IsType(expectedType, el);
}
UiElement? offlineMarker = UiElement.FindDescendant(vassalRow!, 0x100004AAu);
Console.WriteLine($"[socialprobe] allegiance row field OfflineMarker 0x100004AA -> {(offlineMarker is null ? "MISSING" : offlineMarker.GetType().Name)}");
Assert.NotNull(offlineMarker);
// The IgnoreAllegianceRequests checkbox (D7: dimmed on the Options
// Character tab, but still a live read/write toggle here — same
// shape as the fellowship page's four checkboxes).
UiElement? ignoreRequestsCheckbox = UiElement.FindDescendant(tabs, 0x10000262u);
Console.WriteLine(
$"[socialprobe] allegiance checkbox IgnoreAllegianceRequests 0x10000262 -> "
+ $"{(ignoreRequestsCheckbox is null ? "MISSING" : ignoreRequestsCheckbox.GetType().Name)}");
Assert.IsType<UiButton>(ignoreRequestsCheckbox);
// CF-1 + AD-85: the checkbox label/tooltip (0x23000003) and the four
// confirmation-dialog templates + two label captions (0x23000001) —
// resolved verbatim, never substituted (class doc).
string? ignoreRequestsLabel = strings.Resolve(
0x23000003u, DatStringResolver.ComputeHash("ID_PlayerOption_IgnoreAllegianceRequests"));
Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_IgnoreAllegianceRequests -> '{ignoreRequestsLabel}'");
Assert.False(string.IsNullOrEmpty(ignoreRequestsLabel));
foreach (string key in new[]
{
"ID_Allegiance_MonarchLabel",
"ID_Allegiance_PatronSlashMonarchLabel",
"ID_Allegiance_SwearConfirmation",
"ID_Allegiance_BreakConfirmation",
"ID_Allegiance_KickConfirmation",
})
{
string? value = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
Console.WriteLine($"[socialprobe] allegiance string {key} -> '{value}'");
Assert.False(string.IsNullOrEmpty(value));
}
// Full end-to-end Bind against the PRODUCTION mount (mirrors the
// fellowship Bind check above) — proves Bind() finds every element
// it needs with zero "not found" console warnings.
UiElement? allegiancePageForBind = UiElement.FindDescendant(tabs, 0x10000291u);
Assert.NotNull(allegiancePageForBind);
var allegianceOriginalOut = Console.Out;
var allegianceCapture = new StringWriter();
Console.SetOut(allegianceCapture);
SocialAllegiancePageController? allegianceController;
try
{
allegianceController = SocialAllegiancePageController.Bind(
allegiancePageForBind!,
new SocialAllegiancePageController.Bindings(
Snapshot: () => new RuntimeAllegianceSnapshot(),
Monarch: () => null,
Patron: _ => null,
Member: _ => null,
Vassals: _ => [],
Swear: _ => default,
Break: _ => default,
Kick: _ => default,
SetUpdateSubscription: _ => default,
Selection: new AcDream.Core.Selection.SelectionState(),
LocalPlayerGuid: () => 0u,
CurrentCharacterOption: _ => false,
SetCharacterOption: (_, _) => { },
TemplateResolver: rowTemplates.Resolve,
ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
ResolveWorldObjectName: _ => null,
ShowConfirmation: (_, _) => 0u));
}
finally
{
Console.SetOut(allegianceOriginalOut);
}
string allegianceBindLog = allegianceCapture.ToString();
Console.WriteLine($"[socialprobe] allegiance Bind() console output:\n{allegianceBindLog}");
Assert.NotNull(allegianceController);
Assert.DoesNotContain("not found", allegianceBindLog);
// Tab button captions — non-empty (the #375 resolver class: a missing // Tab button captions — non-empty (the #375 resolver class: a missing
// string resolver renders blank captions even though the layout mounts). // string resolver renders blank captions even though the layout mounts).
foreach (UiTabTableEntry t in tabs.Tabs) foreach (UiTabTableEntry t in tabs.Tabs)