acdream/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
Erik 67fe754dd6 fix: social gate round 2, part 2 - confirmation-dialog sentences + the
refused-drop yellow notice

Item 4 (confirmation dialogs missing text + names): the missing retail
mechanism was StringTable template substitution - an entry is N+1 literal
fragments interleaved with N named variables, composed by
StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7).
ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps
it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2,
the exact compute_str_hash space; Chorizite stores the variable hashes
directly):

- Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 ->
  ID_Allegiance_AcceptSwearConfirmation, injected into
  GameplayConfirmationController; null resolve falls back to the bare
  wire message, never invented English. The 2/3/5/6 " Continue?" family
  never consults the composer.
- Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed
  the dangling "Do you wish to swear to ") is replaced by click-time
  ResolveTemplate with the target's name.

All five templates verified token-free in the installed DAT - this is
NOT a StringTableMetaLanguage port (AD-81's engine caveat stands).

Item 5 (refused drop shows nothing; retail shows yellow top-center
text): the prevRequest latch was ALREADY ported (InventoryTransactionState);
what was missing was the consumer. InventoryTransactionState now raises
RequestFailed(request, weenieError) when a 0x00A0 clears the latch;
ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's
"The <item> can't be <verb>" (verb table + suffix map ported verbatim in
Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes
it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow
top-center line. The dispatcher's second leg (@0x0055B342) also runs:
outside the 7-code exclusion set, WeenieErrorMessages resolves per-code
text/destination; 0x426 AttunedItem has no row in either place beyond
the verb line - faithful single-line output.

Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the
token-free interleave is now ported; meta-token engine + FormatName
remain), AD-93 filed (wire-guid-match vs retail's latched-guid
preference; no Move/Wield latch kinds).

Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate
(constructed StringTable fixtures), +1 composer injection, +1 end-to-end
refused-drop line. Core 4,697/1 skip, App 4,983/3 skips.

Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:10:42 +02:00

794 lines
36 KiB
C#

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 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>
/// <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>
/// <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>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 IReadOnlyList<UiText.Line> BlankLine =
[new UiText.Line(" ", TextColor)];
private static readonly Func<IReadOnlyList<UiText.Line>> BlankLineProvider = () => BlankLine;
/// <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,
// (templateKey, playerName) -> the composed confirmation sentence via
// StringTable::GetString's fragment/PLAYER-variable interleave
// (DatStringResolver.ResolveTemplate). Null delegate or null result →
// the caller falls back to the bare name, never invented English.
Func<string, string, string?>? ResolvePlayerTemplate = null);
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 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 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.
/// The swear/break/kick confirmation SENTENCES are not latched here:
/// they need the target's name, so they compose at click time through
/// <see cref="Bindings.ResolvePlayerTemplate"/> (retail's own
/// <c>MakeXxxConfirmationDialog</c> shape — 2026-08-13 AD-85 fix).</summary>
private readonly string? _monarchLabelCaption;
private readonly string? _patronSlashMonarchLabelCaption;
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,
UiText? monarchLabel,
UiText? monarchName,
UiText? monarchFollowers,
UiElement? monarchIsPatronSubBlock,
UiText? monarchExperiencePassedUp,
UiElement patronField,
UiText? patronName,
UiText? patronExperiencePassedUp,
UiTemplateListBox? vassalListBox,
UiButton? ignoreRequestsCheckbox,
UiButton? swearButton,
UiButton? breakButton,
UiButton? kickButton,
string? monarchLabelCaption,
string? patronSlashMonarchLabelCaption)
{
_bindings = bindings;
_selfName = selfName;
_selfFollowers = selfFollowers;
_selfRank = selfRank;
_monarchField = monarchField;
_monarchLabel = monarchLabel;
_monarchName = monarchName;
_monarchFollowers = monarchFollowers;
_monarchIsPatronSubBlock = monarchIsPatronSubBlock;
_monarchExperiencePassedUp = monarchExperiencePassedUp;
_patronField = patronField;
_patronName = patronName;
_patronExperiencePassedUp = patronExperiencePassedUp;
_vassalListBox = vassalListBox;
_ignoreRequestsCheckbox = ignoreRequestsCheckbox;
_swearButton = swearButton;
_breakButton = breakButton;
_kickButton = kickButton;
_monarchLabelCaption = monarchLabelCaption;
_patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption;
}
public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field "
+ $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — "
+ "allegiance page will not present.");
return null;
}
// 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"));
var controller = new SocialAllegiancePageController(
bindings,
selfName, selfFollowers, selfRank,
monarchField, monarchLabel, monarchName, monarchFollowers,
monarchIsPatronSubBlock, monarchExperiencePassedUp,
patronField, patronName, patronExperiencePassedUp,
vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton,
monarchLabelCaption, patronSlashMonarchLabelCaption);
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;
// 2026-08-13 gate fix ("can't press Ignore Allegiance requests —
// always checked"): identical double-toggle to the fellowship page's
// checkboxes — the authored ToggleBehavior self-flip made the old
// `!Selected` read the flipped value and write the ORIGINAL back.
// Same CH6a/b mirror discipline: suppress the self-flip, derive the
// next value from the STORE, per-tick seeding mirrors the outcome.
_ignoreRequestsCheckbox.SuppressSelfToggle = true;
_ignoreRequestsCheckbox.OnClick = () =>
{
bool next = !_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests);
_bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next);
};
}
/// <summary>
/// <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;
// "Do you wish to swear to <target>?" — StringTable template
// ID_Allegiance_SwearConfirmation with the PLAYER slot filled at
// click time (retail's MakeSwearConfirmationDialog). Null resolve →
// the bare name, never invented English.
string message = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_SwearConfirmation", name) ?? 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 = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_BreakConfirmation", patron.Name) ?? 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 = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_KickConfirmation", vassal.Name) ?? 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()
{
RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot();
uint selfGuid = _bindings.LocalPlayerGuid();
RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null;
RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null;
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);
// 2026-08-13 gate ("On top it just says 0 then 0"): the bare numbers
// gain their labels in the user-specified format — the full retail
// StringInfo composition for these fields remains AD-85's gap; this
// is its user-directed partial fill.
SetLine(_selfFollowers, ref _lastSelfFollowers, $"Followers: {snapshot.TotalVassals}", TextColor);
SetLine(_selfRank, ref _lastSelfRank, $"Rank: [{snapshot.Rank}]", 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);
// 2026-08-13 gate: same label treatment as the self block's followers.
SetLine(
_monarchFollowers,
ref _lastMonarchFollowers,
$"Followers: {(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u)}",
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)
{
// FA5 mechanism-review SF-1 (2026-08-12): retail's
// UpdateVassalsData @004924c3 writes the vassal name with NO
// colour change for offline — the offline cue is EXCLUSIVELY the
// authored 0x100004AA marker (toggled below). The vassal name
// always renders in the normal TextColor; greying it was an
// invented visual and is removed.
string name = vassal.Name;
nameText.LinesProvider = () => [new UiText.Line(name, TextColor)];
}
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;
}
}