diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index aecc6c82..b0bc15a7 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -897,7 +897,29 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory isOpen => late.GameRuntime.FellowshipSetOpen(isOpen), panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen), d.Actions.Selection, - () => d.PlayerIdentity.ServerGuid), + () => d.PlayerIdentity.ServerGuid, + // Campaign FA slice FA5: IRuntimeAllegianceView's out-param + // accessors projected into nullable-returning delegates — + // the same shape SocialAllegiancePageController.Bindings + // wants, mirroring the Fellowship projection above. + AllegianceMonarch: () => + d.Runtime.Allegiance.TryGetMonarch(out var monarch) + ? monarch + : (RuntimeAllegianceMemberSnapshot?)null, + AllegiancePatron: guid => + d.Runtime.Allegiance.TryGetPatron(guid, out var patron) + ? patron + : (RuntimeAllegianceMemberSnapshot?)null, + AllegianceMember: guid => + d.Runtime.Allegiance.TryGetMember(guid, out var member) + ? member + : (RuntimeAllegianceMemberSnapshot?)null, + AllegianceVassals: guid => d.Runtime.Allegiance.GetVassals(guid), + AllegianceSwear: guid => late.GameRuntime.AllegianceSwear(guid), + AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid), + AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid), + AllegianceSetUpdateSubscription: on => + late.GameRuntime.AllegianceSetUpdateSubscription(on)), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 12b410c4..18ea229f 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -158,6 +158,29 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.Fellowship.SetPanelOpen( generation, panelOpen)); + // ── Campaign FA slice FA5: allegiance page commands ───────────────── + // Same shape as the fellowship block above. + + public RuntimeCommandResult AllegianceSwear(uint patronGuid) => + Invoke((commands, generation) => commands.Allegiance.Swear( + generation, patronGuid)); + + public RuntimeCommandResult AllegianceBreak(uint targetGuid) => + Invoke((commands, generation) => commands.Allegiance.Break( + generation, targetGuid)); + + public RuntimeCommandResult AllegianceKick(uint vassalGuid) => + Invoke((commands, generation) => commands.Allegiance.Kick( + generation, vassalGuid)); + + /// 0x001F — CF-1's data subscription toggle (the + /// allegiance-page analogue of 's + /// 0x00A6). See + /// for the three retail arming points this forwards. + public RuntimeCommandResult AllegianceSetUpdateSubscription(bool on) => + Invoke((commands, generation) => commands.Allegiance.SetUpdateSubscription( + generation, on)); + public void Deactivate() { lock (_gate) diff --git a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs index da360b14..e1752484 100644 --- a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs +++ b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs @@ -1,137 +1,782 @@ using System; using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Net.Messages; using AcDream.Runtime; namespace AcDream.App.UI.Layout; /// -/// Campaign FA slice FA3: the Allegiance page's empty-state presentation — -/// the ONLY behavior this SHELL slice owns for this page (live monarch/ -/// patron/vassal population, swear/break/kick + confirmations, and the -/// per-member online-state dimming are FA5 scope). +/// Campaign FA slice FA5: the Allegiance page fully live — the 0x001F +/// 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 +/// gate — see that +/// slice's own class doc, now superseded by this one. /// /// -/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it -/// hides the monarch/patron blocks and blanks their name text to a literal -/// space per-block, gated on whether each relationship exists -/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract -/// simplifies this to the single gate this campaign's Runtime owner -/// actually exposes today: -/// — no allegiance push has landed this generation. When a profile HAS -/// arrived, this controller leaves both blocks visible with their -/// build-time (empty) text rather than fabricate monarch/patron content — -/// that population is FA5's job. +/// CF-1 — the data subscription. +/// docs/research/2026-08-11-fa-allegiance-wire.md §1.2 pins THREE +/// retail arming points for CM_Allegiance::Event_UpdateRequest(u32) +/// (opcode 0x001F), all sending 1 except the last: +/// gmAllegianceUI::PostInit @0x004911C6, RecvNotice_PlayerDescReceived +/// @0x00490D40, and OnVisibilityChanged's visible/hidden branches +/// (@0x004912DD/@0x00491311). attempts the +/// PostInit arm (almost always a pre-world no-op in this campaign's +/// process-lifetime mount — see 's own +/// doc for why that is not a gap); is +/// the PlayerDescReceived arm, wired to the LiveSession EnteredWorld +/// seam exactly like FA4's 0x00A6; is the +/// visible-branch arm, called by on every +/// "window shown AND Allegiance active" transition. Without this, +/// 0x0020 AllegianceUpdate never arrives on demand (lane C §2 row 4/5) +/// and the panel shows nothing — 0x027B AllegianceInfoResponse is +/// text-only chat (FA2 MF-2's correction) and is NOT wired here. /// /// /// -/// Element ids confirmed by the FA3 live-mount probe: -/// 0x10000255 monarch-field container, 0x1000025A -/// patron-field container, 0x10000257 monarch-name text (inside the -/// monarch field), 0x1000025C patron-name text (inside the patron -/// field). Both lookups are SCOPED to the allegiance page root — the panel -/// also authors 0x10000492 (the XP-passed-up text) TWICE, once under -/// each block, so a flat lookup anywhere in this subsystem would risk -/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP -/// 0x10000211-in-two-layouts lesson). +/// The FA4 MF-3-REOPEN lesson, applied here. A reconnect's generation +/// reset runs BEFORE the new session is in world, where every Runtime +/// command is world-gated (Inactive, nothing sent). +/// (wired to the pre-world reset seam) +/// ONLY clears the local latch — it must never itself declare. +/// (wired to the POST-world seam) +/// does the actual send, and — unlike — is +/// UNCONDITIONAL: it does not compare against the current latch first, +/// because retail's own RecvNotice_PlayerDescReceived arm does not +/// check current visibility either (§1.2's table: it always sends 1). +/// 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. /// /// /// -/// FA5 scope note (fix-round mechanism SF-7). This shell's gate is -/// coarser than retail's: gmAllegianceUI::UpdateMonarchData -/// @0x00491B40 hides the monarch block when there is no monarch OR the -/// monarch IS the viewer, and hides the patron block on the analogous -/// per-relationship test — this controller instead gates BOTH blocks on -/// the single flag. The -/// plan's FA5 row records the two per-relationship acceptance lines FA5 -/// owes. Also note for whoever lands FA5: reassigns -/// UNCONDITIONALLY every frame — FA5's -/// real monarch/patron name population must change this method at the -/// same time, or its content will be overwritten on the very next frame. +/// Per-relationship empty-state gate (fix-round SF-7). +/// gmAllegianceUI::UpdateMonarchData @0x00491B40 hides the monarch +/// block when there is no monarch OR the monarch IS the viewer; +/// UpdatePatronData @0x004917C0 hides the patron block when there is +/// no patron OR the patron IS the monarch (in which case the monarch block +/// relabels via ID_Allegiance_PatronSlashMonarchLabel and reveals its +/// 0x10000490 sub-block, byte-verified at the same two functions). +/// Both hidden branches blank their name text to a literal single space — +/// UpdateMonarchData ALSO blanks the monarch-followers text in its +/// hidden branch (@0x00491FCD), a detail the coarser FA3 gate did not +/// need to model. +/// +/// +/// +/// Numeric-only text, no invented English (register row AD-85). Every +/// retail string site beyond a bare name or a variable-free caption +/// (ID_Allegiance_MonarchLabel/PatronSlashMonarchLabel, which +/// carry NO StringInfo variables and so resolve and render exactly as +/// authored) needs StringInfo 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. +/// +/// +/// +/// Field sources, decompiled (`gmAllegianceUI::UpdatePlayerData +/// @0x00491330`, UpdateMonarchData, UpdatePatronData). +/// 0x10000251 = the ALLEGIANCE's own name (_allegiance.m_AllegianceName +/// — "CharacterName" in the id is a misnomer), not the viewer's own name. +/// 0x10000252 ("your followers") = _total_vassals directly. +/// 0x10000258 (monarch followers) = _total_members - 1. The +/// self-rank field (0x10000253) queries a LIVE buffed quality +/// (CBaseQualities::InqInt(qualities, 0x1e), i.e. +/// PropertyInt.AllegianceRank) plus AllegianceData::GetTitle'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 +/// (same 0x0020 message, same underlying stat in every observed case) +/// rendered bare. The "experience passed up" text (0x10000492, doubled +/// under 0x10000490 and the patron field) and the vassal row's own +/// 0x10000269 both source AllegianceData::_cp_tithed — the +/// VIEWER's own tithed amount under the monarch/patron blocks, the VASSAL's +/// own tithed amount in each row. /// /// 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; + + /// Child of — shown only when the + /// viewer's patron IS the monarch (SF-7). + private const uint MonarchIsPatronSubBlockId = 0x10000490u; + + /// Authored TWICE inside this page (§6 DISCIPLINE) — once under + /// , once directly under + /// . Every lookup MUST be scoped to the + /// correct parent; a flat FindDescendant from the page root would + /// non-deterministically pick one instance for both roles. + 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 BlankLine = - [new UiText.Line(" ", System.Numerics.Vector4.One)]; - - private static readonly IReadOnlyList NoLines = []; - - /// - /// Fix-round mechanism SF-2 / blast SF-4: hoisted - /// delegates. runs every frame regardless of panel - /// visibility (see class doc); the original () => lines 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. - /// + [new UiText.Line(" ", TextColor)]; private static readonly Func> BlankLineProvider = () => BlankLine; - private static readonly Func> NoLinesProvider = () => NoLines; + + /// Sentinel passed to for the "hidden, + /// blanked" state — deliberately NOT , since the + /// backing _lastXxx fields also start at + /// (uninitialized). Using as both the sentinel + /// AND the initial value would make the very first blank-state + /// a false "unchanged" no-op, leaving the widget at + /// its import-time (non-blank) text instead of retail's literal single + /// space. + private const string BlankSentinel = "blank"; + + /// The live read/write seam this page binds to — mirrors + /// 's shape. + /// /// + /// project 's + /// out-param accessors into plain nullable-returning delegates. + /// 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 + /// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE), the SAME + /// resolver ToolbarRuntimeBindings.ResolveName already wires from + /// ClientObjectTable). + public sealed record Bindings( + Func Snapshot, + Func Monarch, + Func Patron, + Func Member, + Func> Vassals, + Func Swear, + Func Break, + Func Kick, + Func SetUpdateSubscription, + AcDream.Core.Selection.SelectionState Selection, + Func LocalPlayerGuid, + Func CurrentCharacterOption, + Action SetCharacterOption, + Func TemplateResolver, + Func ResolveString, + Func ResolveWorldObjectName, + Func, uint> ShowConfirmation); + + private readonly record struct VassalRowWidgets( + UiText? Name, + UiText? ExperiencePassedUp, + UiElement? OfflineMarker); + + private readonly Bindings _bindings; + + private readonly UiText? _selfName; + private readonly UiText? _selfFollowers; + private readonly UiText? _selfRank; private readonly UiElement _monarchField; - private readonly UiElement _patronField; + private readonly UiText? _monarchLabel; private readonly UiText? _monarchName; + private readonly UiText? _monarchFollowers; + private readonly UiElement? _monarchIsPatronSubBlock; + private readonly UiText? _monarchExperiencePassedUp; + + private readonly UiElement _patronField; private readonly UiText? _patronName; - private readonly Func _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; + + /// Bind-time-resolved (never per-tick — same discipline every + /// other DatStringResolver consumer in this codebase follows). + /// Null when resolution failed — the affected widget then keeps its + /// import-time text/caption rather than showing invented English. + private readonly string? _monarchLabelCaption; + private readonly string? _patronSlashMonarchLabelCaption; + private readonly string? _swearConfirmationTemplate; + private readonly string? _breakConfirmationTemplate; + private readonly string? _kickConfirmationTemplate; + + private readonly Dictionary _rows = new(); + private readonly HashSet _vassalGuids = new(); + + private uint _selectedVassalGuid; + private long _lastRosterRevision = long.MinValue; + + /// CF-1's local latch — see the class doc's CF-1 section. + /// Advances only on . + private bool _subscribed; + + private string? _lastSelfName; + private string? _lastSelfFollowers; + private string? _lastSelfRank; + private string? _lastMonarchName; + private string? _lastMonarchFollowers; + private string? _lastMonarchExperiencePassedUp; + private string? _lastPatronName; + private string? _lastPatronExperiencePassedUp; private SocialAllegiancePageController( + Bindings bindings, + UiText? selfName, + UiText? selfFollowers, + UiText? selfRank, UiElement monarchField, - UiElement patronField, + UiText? monarchLabel, UiText? monarchName, + UiText? monarchFollowers, + UiElement? monarchIsPatronSubBlock, + UiText? monarchExperiencePassedUp, + UiElement patronField, UiText? patronName, - Func snapshot) + UiText? patronExperiencePassedUp, + UiTemplateListBox? vassalListBox, + UiButton? ignoreRequestsCheckbox, + UiButton? swearButton, + UiButton? breakButton, + UiButton? kickButton, + string? monarchLabelCaption, + string? patronSlashMonarchLabelCaption, + string? swearConfirmationTemplate, + string? breakConfirmationTemplate, + string? kickConfirmationTemplate) { + _bindings = bindings; + _selfName = selfName; + _selfFollowers = selfFollowers; + _selfRank = selfRank; _monarchField = monarchField; - _patronField = patronField; + _monarchLabel = monarchLabel; _monarchName = monarchName; + _monarchFollowers = monarchFollowers; + _monarchIsPatronSubBlock = monarchIsPatronSubBlock; + _monarchExperiencePassedUp = monarchExperiencePassedUp; + _patronField = patronField; _patronName = patronName; - _snapshot = snapshot; + _patronExperiencePassedUp = patronExperiencePassedUp; + _vassalListBox = vassalListBox; + _ignoreRequestsCheckbox = ignoreRequestsCheckbox; + _swearButton = swearButton; + _breakButton = breakButton; + _kickButton = kickButton; + _monarchLabelCaption = monarchLabelCaption; + _patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption; + _swearConfirmationTemplate = swearConfirmationTemplate; + _breakConfirmationTemplate = breakConfirmationTemplate; + _kickConfirmationTemplate = kickConfirmationTemplate; } - public static SocialAllegiancePageController? Bind( - UiElement pageRoot, - Func snapshot) + public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings) { ArgumentNullException.ThrowIfNull(pageRoot); - ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(bindings); - if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField - || UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField) + if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField + || UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField) { Console.WriteLine( "[D.2b] SocialAllegiancePageController: monarch/patron field " - + "containers (0x10000255/0x1000025A) not found — allegiance page " - + "will not present its empty state."); + + $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — " + + "allegiance page will not present."); return null; } - // Scoped to each block — 0x10000257/0x1000025C are unique per-block, - // but the lookup discipline matters equally here (see class doc). - UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText; - UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText; - if (monarchName is null) - Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found."); - if (patronName is null) - Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found."); + // Scoped lookups — see class doc + §6 DISCIPLINE. 0x10000492 is + // authored TWICE; each instance is resolved under its OWN parent. + UiElement? monarchIsPatronSubBlock = + UiElement.FindDescendant(monarchField, MonarchIsPatronSubBlockId); + UiText? monarchExperiencePassedUp = monarchIsPatronSubBlock is null + ? null + : UiElement.FindDescendant(monarchIsPatronSubBlock, ExperiencePassedUpTextId) as UiText; + UiText? patronExperiencePassedUp = + UiElement.FindDescendant(patronField, ExperiencePassedUpTextId) as UiText; + + UiText? selfName = UiElement.FindDescendant(pageRoot, SelfNameTextId) as UiText; + UiText? selfFollowers = UiElement.FindDescendant(pageRoot, SelfFollowersTextId) as UiText; + UiText? selfRank = UiElement.FindDescendant(pageRoot, SelfRankTextId) as UiText; + UiText? monarchLabel = UiElement.FindDescendant(monarchField, MonarchLabelTextId) as UiText; + UiText? monarchName = UiElement.FindDescendant(monarchField, MonarchNameTextId) as UiText; + UiText? monarchFollowers = UiElement.FindDescendant(monarchField, MonarchFollowersTextId) as UiText; + UiText? patronName = UiElement.FindDescendant(patronField, PatronNameTextId) as UiText; + + UiTemplateListBox? vassalListBox = + UiElement.FindDescendant(pageRoot, VassalListBoxId) as UiTemplateListBox; + if (vassalListBox is null) + Console.WriteLine( + $"[D.2b] SocialAllegiancePageController: ListBox 0x{VassalListBoxId:X8} not " + + "found — the vassal list will not populate."); + else + { + vassalListBox.TemplateResolver = bindings.TemplateResolver; + uint scrollbarElementId = vassalListBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(pageRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) + scrollbar.Model = vassalListBox.Scroll; + else + Console.WriteLine( + $"[D.2b] SocialAllegiancePageController: scrollbar 0x{scrollbarElementId:X8} " + + "not found — the vassal list will not scroll."); + } + + UiButton? ignoreRequestsCheckbox = + UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton; + UiButton? swearButton = UiElement.FindDescendant(pageRoot, SwearButtonId) as UiButton; + UiButton? breakButton = UiElement.FindDescendant(pageRoot, BreakButtonId) as UiButton; + UiButton? kickButton = UiElement.FindDescendant(pageRoot, KickButtonId) as UiButton; + + // Bind-time only (class doc's numeric-only-text section). No + // StringInfo variables on either label — safe to use verbatim. + string? monarchLabelCaption = bindings.ResolveString( + StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_MonarchLabel")); + string? patronSlashMonarchLabelCaption = bindings.ResolveString( + StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_PatronSlashMonarchLabel")); + + // AD-85: unsubstituted retail template text, used verbatim (never + // blended with an invented sentence) — see class doc. + string? swearConfirmationTemplate = bindings.ResolveString( + StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_SwearConfirmation")); + string? breakConfirmationTemplate = bindings.ResolveString( + StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_BreakConfirmation")); + string? kickConfirmationTemplate = bindings.ResolveString( + StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_KickConfirmation")); var controller = new SocialAllegiancePageController( - monarchField, patronField, monarchName, patronName, snapshot); + bindings, + selfName, selfFollowers, selfRank, + monarchField, monarchLabel, monarchName, monarchFollowers, + monarchIsPatronSubBlock, monarchExperiencePassedUp, + patronField, patronName, patronExperiencePassedUp, + vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton, + monarchLabelCaption, patronSlashMonarchLabelCaption, + swearConfirmationTemplate, breakConfirmationTemplate, kickConfirmationTemplate); + + controller.WireButtons(); + controller.WireCheckbox(); controller.Tick(); + + // CF-1's PostInit arm (gmAllegianceUI::PostInit @0x004911C6): almost + // always a pre-world no-op in this campaign's process-lifetime mount + // (Bind runs once, at UI construction, before any connection) — see + // RedeclareAfterWorldEntry for the arm that actually matters in + // practice. Attempted anyway for citation completeness and the rare + // case a host mounts post-world. + controller.SetPageVisible(true); + return controller; } + private void WireButtons() + { + if (_swearButton is not null) + _swearButton.OnClick = OnSwearClick; + if (_breakButton is not null) + _breakButton.OnClick = OnBreakClick; + if (_kickButton is not null) + _kickButton.OnClick = OnKickClick; + } + + private void WireCheckbox() + { + if (_ignoreRequestsCheckbox is null) return; + + const string labelKey = "ID_PlayerOption_IgnoreAllegianceRequests"; + string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey)); + if (label is not null) + _ignoreRequestsCheckbox.Label = label; + else + Console.WriteLine( + $"[D.2b] SocialAllegiancePageController: label '{labelKey}' did not resolve — " + + "checkbox renders with no caption rather than invented English."); + + string? tooltip = _bindings.ResolveString( + OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); + if (tooltip is not null) + _ignoreRequestsCheckbox.TooltipText = tooltip; + + _ignoreRequestsCheckbox.OnClick = () => + { + bool next = !_ignoreRequestsCheckbox.Selected; + _ignoreRequestsCheckbox.Selected = next; + _bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next); + }; + } + /// - /// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap - /// — called every frame from (same - /// reasoning as ). + /// gmAllegianceUI::UpdateSwearButton @0x004908E0's dialog half — + /// the TARGET is latched here, at click time (retail's own + /// m_iidPossibleNewPatron 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). + /// + 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); + }); + } + + /// gmAllegianceUI::UpdateBreakButton @0x004909D0 + + /// MakeBreakConfirmationDialog @0x00492BF0: target = the viewer's + /// current patron, latched at click time. + 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); + }); + } + + /// MakeKickConfirmationDialog @0x00492E10: target = the + /// currently-selected vassal ROW (panel-local selection, + /// m_iidSelectedVassal — NOT the world selection; lane A §6.2 + /// confirms Allegiance's list-selection message has no + /// ACCWeenieObject::SetSelectedObject call, unlike Fellowship's). + 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); + }); + } + + /// + /// Re-reads live state and applies retail's per-relationship gate, + /// roster diff, and button-enable rules. Called every frame from + /// unconditionally (same + /// reasoning FA3 established: cheap even while the panel is hidden — no + /// DAT access outside the revision-gated roster rebuild branch). /// public void Tick() { - bool hasProfile = _snapshot().HasProfile; - _monarchField.Visible = hasProfile; - _patronField.Visible = hasProfile; + RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot(); + uint selfGuid = _bindings.LocalPlayerGuid(); + RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null; + RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null; - Func> provider = hasProfile ? NoLinesProvider : BlankLineProvider; - if (_monarchName is not null) _monarchName.LinesProvider = provider; - if (_patronName is not null) _patronName.LinesProvider = provider; + RefreshSelfBlock(snapshot); + RefreshMonarchBlock(snapshot, monarch, patron); + RefreshPatronBlock(snapshot, monarch, patron); + + if (snapshot.Revision != _lastRosterRevision) + { + _lastRosterRevision = snapshot.Revision; + RefreshRoster(selfGuid); + } + + RefreshCheckboxSelection(); + RefreshButtonStates(snapshot, selfGuid, patron); + } + + // gmAllegianceUI::UpdatePlayerData @0x00491330 — written unconditionally + // (lane A §4.5), no HasProfile gate; a zeroed/empty snapshot degrades to + // an empty name and zero counters naturally, not a special case. + private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot) + { + SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor); + SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor); + SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor); + } + + // gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc. + private void RefreshMonarchBlock( + RuntimeAllegianceSnapshot snapshot, + RuntimeAllegianceMemberSnapshot? monarch, + RuntimeAllegianceMemberSnapshot? patron) + { + bool hasMonarch = monarch is { } m && m.CharacterId != _bindings.LocalPlayerGuid(); + _monarchField.Visible = hasMonarch; + + if (!hasMonarch) + { + SetProvider(_monarchName, ref _lastMonarchName, BlankSentinel, BlankLineProvider); + SetProvider(_monarchFollowers, ref _lastMonarchFollowers, BlankSentinel, BlankLineProvider); + if (_monarchIsPatronSubBlock is not null) _monarchIsPatronSubBlock.Visible = false; + return; + } + + RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value; + SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor); + SetLine( + _monarchFollowers, + ref _lastMonarchFollowers, + (snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(), + TextColor); + _monarchField.Enabled = monarchData.IsLoggedIn; + + bool patronIsMonarch = patron is { } p && p.CharacterId == monarchData.CharacterId; + string? label = patronIsMonarch ? _patronSlashMonarchLabelCaption : _monarchLabelCaption; + if (_monarchLabel is not null && label is not null) + _monarchLabel.LinesProvider = () => [new UiText.Line(label, TextColor)]; + + if (_monarchIsPatronSubBlock is not null) + _monarchIsPatronSubBlock.Visible = patronIsMonarch; + + if (patronIsMonarch) + { + uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u; + SetLine(_monarchExperiencePassedUp, ref _lastMonarchExperiencePassedUp, tithed.ToString(), TextColor); + } + } + + // gmAllegianceUI::UpdatePatronData @0x004917C0 — see class doc. + private void RefreshPatronBlock( + RuntimeAllegianceSnapshot snapshot, + RuntimeAllegianceMemberSnapshot? monarch, + RuntimeAllegianceMemberSnapshot? patron) + { + bool hasPatron = patron is { } p + && (monarch is not { } m || p.CharacterId != m.CharacterId); + _patronField.Visible = hasPatron; + + if (!hasPatron) + { + SetProvider(_patronName, ref _lastPatronName, BlankSentinel, BlankLineProvider); + return; + } + + RuntimeAllegianceMemberSnapshot patronData = patron!.Value; + SetLine(_patronName, ref _lastPatronName, patronData.Name, TextColor); + _patronField.Enabled = patronData.IsLoggedIn; + + uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u; + SetLine(_patronExperiencePassedUp, ref _lastPatronExperiencePassedUp, tithed.ToString(), TextColor); + } + + private void RefreshCheckboxSelection() + { + if (_ignoreRequestsCheckbox is null) return; + _ignoreRequestsCheckbox.Selected = + _bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests); + } + + // gmAllegianceUI::UpdateSwearButton/UpdateBreakButton (lane A §4.4) + the + // Kick enable check inlined at the end of gmAllegianceUI::Update. + private void RefreshButtonStates( + RuntimeAllegianceSnapshot snapshot, + uint selfGuid, + RuntimeAllegianceMemberSnapshot? patron) + { + if (_swearButton is not null) + { + uint? targetGuid = _bindings.Selection.SelectedObjectId; + // AD-84: retail additionally requires the target to be a player + // (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap + // classification for this, same superset-of-retail limitation + // AD-83 already accepted for the Fellowship page's Recruit + // button. The server refuses a non-player target the same way + // retail's own disabled button would have silently no-op'd. + bool targetValid = targetGuid is { } id + && id != selfGuid + && _bindings.Member(id) is null; + _swearButton.Enabled = patron is null && targetValid; + } + + if (_breakButton is not null) + _breakButton.Enabled = patron is not null; + + if (_kickButton is not null) + _kickButton.Enabled = _selectedVassalGuid != 0u; + } + + /// + /// Diffs the live vassal GUID set against the roster — unchanged set + /// updates rows in place; a real join/leave/kick rebuilds via + /// (the FA3/FA4 + /// carry-forward — do NOT reintroduce the FA3 Flush() scroll + /// reset here). + /// + private void RefreshRoster(uint selfGuid) + { + if (_vassalListBox is null) return; + + var vassals = new List(_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 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; + } + + /// + /// CF-1's visible-branch arm — edge-triggered exactly like FA4's + /// SocialFellowshipPageController.SetPageVisible. Called by + /// on every "window shown AND + /// Allegiance active" transition, and once from as + /// the PostInit attempt (see class doc). + /// + public void SetPageVisible(bool visible) + { + if (_subscribed == visible) return; + if (_bindings.SetUpdateSubscription(visible).Status == RuntimeCommandStatus.Accepted) + _subscribed = visible; + } + + /// + /// Pre-world generation-reset seam — clears the latch WITHOUT sending + /// anything, mirroring SocialFellowshipPageController.ResetPageVisibleLatch. + /// + public void ResetPageVisibleLatch() => _subscribed = false; + + /// + /// CF-1's PlayerDescReceived arm — the post-world EnteredWorld + /// 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 + /// . + /// + 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> provider) + { + if (text is null) return; + if (lastValue == sentinelValue) return; + lastValue = sentinelValue; + text.LinesProvider = provider; } } diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index b6e7e60d..8713e362 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -91,11 +91,15 @@ public sealed class SocialPanelController : IRetainedPanelController /// FellowshipSnapshot field with the page's full read/write seam /// (roster enumeration, the seven commands, selection, character /// options, string resolution — see - /// 's own doc). + /// 's own doc). + /// Campaign FA slice FA5: is the same + /// widening for the Allegiance page, replacing FA3's bare + /// AllegianceSnapshot field — see + /// 's own doc. public sealed record Callbacks( Action Toggle, SocialFellowshipPageController.Bindings Fellowship, - Func AllegianceSnapshot, + SocialAllegiancePageController.Bindings Allegiance, FriendsState Friends, SquelchState Squelch, Func TemplateResolver); @@ -148,7 +152,15 @@ public sealed class SocialPanelController : IRetainedPanelController // very first one ActivateTabBehavior's default-entry activation // fires. Fix-round SF-4: stored as a field (not an inline lambda) // so Dispose can unsubscribe it. - _onActivePageChanged = (_, _) => UpdateFellowshipPageVisibility(); + // Campaign FA slice FA5: the Allegiance page's CF-1 subscription + // (0x001F) has the identical "window shown AND MY tab active" + // conjunction as Fellowship's 0x00A6 — folded into the same event + // subscription rather than a second one. + _onActivePageChanged = (_, _) => + { + UpdateFellowshipPageVisibility(); + UpdateAllegiancePageVisibility(); + }; _tabPanel.ActivePageChanged += _onActivePageChanged; } @@ -187,7 +199,7 @@ public sealed class SocialPanelController : IRetainedPanelController : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship); SocialAllegiancePageController? allegiance = allegiancePage is null ? null - : SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot); + : SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance); SocialFriendsPageController? friends = friendsPage is null ? null : SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver); @@ -263,6 +275,7 @@ public sealed class SocialPanelController : IRetainedPanelController { _visible = true; UpdateFellowshipPageVisibility(); + UpdateAllegiancePageVisibility(); } /// hook, fired on every @@ -271,6 +284,7 @@ public sealed class SocialPanelController : IRetainedPanelController { _visible = false; UpdateFellowshipPageVisibility(); + UpdateAllegiancePageVisibility(); } /// D4: the Fellowship page is "visible" (and therefore @@ -279,6 +293,14 @@ public sealed class SocialPanelController : IRetainedPanelController private void UpdateFellowshipPageVisibility() => _fellowship?.SetPageVisible(_visible && IsShowingFellowship); + /// 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 + /// — gated on + /// (initially false) the same way Fellowship's conjunction is. + private void UpdateAllegiancePageVisibility() => + _allegiance?.SetPageVisible(_visible && IsShowingAllegiance); + /// /// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called /// from , a seam @@ -291,8 +313,14 @@ public sealed class SocialPanelController : IRetainedPanelController /// dropped (Inactive). The actual re-declaration is /// , wired to the post-world /// EnteredWorld seam (FA4 re-review REOPEN, 2026-08-12). + /// Campaign FA slice FA5: also clears the Allegiance page's CF-1 + /// 0x001F latch the identical way. /// - public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch(); + public void ResetSessionDeclaration() + { + _fellowship?.ResetPageVisibleLatch(); + _allegiance?.ResetPageVisibleLatch(); + } /// /// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the @@ -306,8 +334,26 @@ public sealed class SocialPanelController : IRetainedPanelController /// default). Idempotent — if a persisted layout already re-showed the /// page and re-declared, the latch is already set /// and this is a no-op. + /// + /// + /// Campaign FA slice FA5: also re-arms the Allegiance page's 0x001F + /// subscription — but via SocialAllegiancePageController. + /// RedeclareAfterWorldEntry, NOT the conjunction-gated + /// UpdateAllegiancePageVisibility: CF-1's PlayerDescReceived arm + /// is UNCONDITIONAL in retail (§1.2 — it always sends 1 + /// regardless of panel visibility), unlike Fellowship's 0x00A6, + /// 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. + /// /// - public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility(); + public void RedeclareAfterWorldEntry() + { + UpdateFellowshipPageVisibility(); + _allegiance?.RedeclareAfterWorldEntry(); + } /// /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 16fc5f6e..ce17be18 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -246,6 +246,20 @@ public sealed record OptionsRuntimeBindings( /// (retail's ACCWeenieObject::selectedID — lane B §2.3, the /// currently-selected WORLD object, not a panel-local selection). /// +/// +/// +/// Campaign FA slice FA5 adds the Allegiance page's write/read surface, +/// mirroring FA4's Fellowship shape: / +/// // +/// project +/// 's out-param +/// accessors into plain nullable-returning delegates (the shape +/// wants), and +/// // +/// / +/// route through +/// exactly like the fellowship commands above. +/// /// public sealed record SocialRuntimeBindings( Func FellowshipSnapshot, @@ -261,7 +275,15 @@ public sealed record SocialRuntimeBindings( Func FellowshipSetOpen, Func FellowshipSetPanelOpen, SelectionState Selection, - Func LocalPlayerGuid); + Func LocalPlayerGuid, + Func AllegianceMonarch, + Func AllegiancePatron, + Func AllegianceMember, + Func> AllegianceVassals, + Func AllegianceSwear, + Func AllegianceBreak, + Func AllegianceKick, + Func AllegianceSetUpdateSubscription); public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, @@ -2773,7 +2795,30 @@ public sealed class RetailUiRuntime : IDisposable SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish( new SetSingleCharacterOptionRuntimeCmd((uint)id, value)), ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)), - AllegianceSnapshot: _bindings.Social.AllegianceSnapshot, + Allegiance: new Layout.SocialAllegiancePageController.Bindings( + Snapshot: _bindings.Social.AllegianceSnapshot, + Monarch: _bindings.Social.AllegianceMonarch, + Patron: _bindings.Social.AllegiancePatron, + Member: _bindings.Social.AllegianceMember, + Vassals: _bindings.Social.AllegianceVassals, + Swear: _bindings.Social.AllegianceSwear, + Break: _bindings.Social.AllegianceBreak, + Kick: _bindings.Social.AllegianceKick, + SetUpdateSubscription: _bindings.Social.AllegianceSetUpdateSubscription, + Selection: _bindings.Social.Selection, + LocalPlayerGuid: _bindings.Social.LocalPlayerGuid, + CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id), + SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish( + new SetSingleCharacterOptionRuntimeCmd((uint)id, value)), + TemplateResolver: TemplateResolver, + ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId), + // ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) — the same + // ClientObjectTable resolver ToolbarRuntimeBindings.ResolveName + // already wires (Swear's target is a WORLD selection, not yet + // an allegiance member, so the allegiance-profile accessors + // above cannot name them). + ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(), + ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)), Friends: _bindings.Social.Friends, Squelch: _bindings.Social.Squelch, TemplateResolver: TemplateResolver); diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs index ca1001e2..c920b911 100644 --- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs @@ -72,6 +72,51 @@ public sealed class GameplayConfirmationControllerTests Assert.Equal(0u, controller.ActiveDialogContext); } + /// + /// Campaign FA slice FA5, item 3: verifies the allegiance-swear + /// confirmation (ConfirmationType.AllegianceSwear, type 1 — + /// RecvNotice_SwearAllegianceRequest -> + /// Handle_Character__ConfirmationRequest @0x005640A0'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 SocialAllegiancePageController 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: + /// Player_Allegiance.cs:91/ConfirmationManager.cs:38), not + /// a full sentence, which this test's message deliberately mirrors + /// rather than inventing retail's unported StringInfo-wrapped + /// sentence (AD-85). + /// + [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(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text))); + + Assert.IsType(shown.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.Equal([(1u, 13u, true)], responses); + Assert.Equal(0u, controller.ActiveDialogContext); + } + [Fact] public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index 58dce906..46bfbcf6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -63,19 +63,66 @@ public sealed class SocialPanelControllerTests ResolveString: resolveString ?? ((_, _) => null)); } + /// Campaign FA slice FA5: the Allegiance page's full read/write + /// seam, mirroring '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 without wiring the rest. + private static SocialAllegiancePageController.Bindings MakeAllegianceBindings( + List? calls = null, + RuntimeAllegianceSnapshot snapshot = default, + RuntimeAllegianceMemberSnapshot? monarch = null, + Func? patron = null, + Func? member = null, + Func>? vassals = null, + AcDream.Core.Selection.SelectionState? selection = null, + uint localPlayerGuid = 0u, + Func? currentCharacterOption = null, + Func? templateResolver = null, + Func? resolveString = null, + Func? resolveWorldObjectName = null, + Func, uint>? showConfirmation = null) + { + calls ??= new List(); + 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( List? calls = null, RuntimeFellowshipSnapshot fellowship = default, RuntimeAllegianceSnapshot allegiance = default, FriendsState? friends = null, SquelchState? squelch = null, - Func? panelOpenInWorld = null) + Func? panelOpenInWorld = null, + SocialAllegiancePageController.Bindings? allegianceBindings = null) { calls ??= new List(); return new SocialPanelController.Callbacks( Toggle: () => calls.Add("toggle"), Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld), - AllegianceSnapshot: () => allegiance, + Allegiance: allegianceBindings ?? MakeAllegianceBindings(calls, allegiance), Friends: friends ?? new FriendsState(), Squelch: squelch ?? new SquelchState(), TemplateResolver: FakeRowTemplateResolver); @@ -205,7 +252,7 @@ public sealed class SocialPanelControllerTests Assert.True(inFellowship.Visible); } - // ── Allegiance empty state (item 4) ───────────────────────────────────── + // ── Allegiance empty state + per-relationship gate (FA5, fix-round SF-7) ─ [Fact] public void Allegiance_NoProfile_HidesBlocksAndBlanksNames() @@ -229,19 +276,335 @@ public sealed class SocialPanelControllerTests Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text); } + /// + /// Fix-round SF-7: gmAllegianceUI::UpdateMonarchData @0x00491B40 + /// hides the monarch block PER-RELATIONSHIP, not on the coarse FA3 + /// HasProfile flag alone — a real monarch who is NOT the viewer + /// shows the block, with the name and _total_members - 1 follower + /// count rendered. + /// [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(); + SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings( + snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 3u }, + monarch: monarch, + localPlayerGuid: selfGuid); + SocialPanelController? controller = SocialPanelController.Bind( - layout, - MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = true })); + layout, MakeCallbacks(allegianceBindings: bindings)); + Assert.NotNull(controller); + + UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!; + Assert.True(monarchField.Visible); + var monarchName = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000257u)); + Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text); + var monarchFollowers = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000258u)); + Assert.Equal("2", Assert.Single(monarchFollowers.LinesProvider()).Text); + } + + /// SF-7's own additional test — the monarch IS the viewer: + /// retail hides the block even though a monarch record exists. + [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); + } + + /// Patron analogue of the monarch test — a real patron who is + /// NOT the monarch shows the block with the patron's name. + [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(UiElement.FindDescendant(patronField, 0x1000025Cu)); + Assert.Equal("Sir Bob", Assert.Single(patronName.LinesProvider()).Text); + } + + /// + /// SF-7's headline case: patron IS the monarch — the patron block hides + /// AND the monarch block's 0x10000490 sub-block (the XP-passed-up + /// line) reveals, matching UpdateMonarchData's + /// PatronSlashMonarchLabel branch. + /// + [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); UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!; UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!; Assert.True(monarchField.Visible); - Assert.True(patronField.Visible); + Assert.False(patronField.Visible); + + UiElement subBlock = UiElement.FindDescendant(monarchField, 0x10000490u)!; + Assert.True(subBlock.Visible); + } + + /// The vassal list populates one row per vassal, in the + /// bindings' own (already-reversed) order — the controller must not + /// re-sort. + [Fact] + public void Allegiance_VassalList_PopulatesOneRowPerVassal() + { + const uint selfGuid = 100u; + var vassals = new List + { + 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( + UiElement.FindDescendant(allegiancePage, 0x10000260u)); + Assert.Equal(2, listBox.ViewportForTest!.Children.Count); + } + + /// Swear latches the WORLD selection at click time and, on + /// accept, sends the swear command for that exact guid. + [Fact] + public void Allegiance_Swear_ShowsConfirmation_AndSendsOnAccept() + { + const uint selfGuid = 100u; + const uint targetGuid = 500u; + var calls = new List(); + Action? 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(UiElement.FindDescendant(allegiancePage, 0x10000263u)); + swearButton.OnClick!(); + + Assert.Contains("confirm:Target Player", calls); + Assert.NotNull(capturedCallback); + capturedCallback!(true); + Assert.Contains($"allegiance-swear:{targetGuid:X8}", calls); + } + + /// Break targets the CURRENT patron (latched at click time), + /// not the world/panel selection. + [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(); + Action? 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(UiElement.FindDescendant(allegiancePage, 0x10000264u)); + breakButton.OnClick!(); + + Assert.NotNull(capturedCallback); + capturedCallback!(true); + Assert.Contains($"allegiance-break:{patronGuid:X8}", calls); + } + + /// 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). + [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(); + Action? 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( + UiElement.FindDescendant(allegiancePage, 0x10000260u)); + var kickButton = Assert.IsType(UiElement.FindDescendant(allegiancePage, 0x10000265u)); + Assert.False(kickButton.Enabled); // nothing selected yet + + UiElement row = Assert.Single(listBox.ViewportForTest!.Children); + var rowName = Assert.IsType(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 ───────────────────────────── + + /// CF-1's visible-branch arm, mirroring + /// FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive. + /// Allegiance is the DEFAULT tab, so the conjunction is already true the + /// instant the window is shown (no tab switch needed). + [Fact] + public void AllegiancePageVisible_DeclaresSubscription_OnWindowShown_AndClearsOnHidden() + { + var calls = new List(); + 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); + } + + /// + /// The exact bug class MF-3-REOPEN caught for Fellowship's 0x00A6, + /// re-verified here for Allegiance's 0x001F: a reconnect must + /// re-subscribe from the POST-world seam, UNCONDITIONALLY — even while + /// the panel is closed, because retail's own RecvNotice_PlayerDescReceived + /// arm does not check panel visibility either (§1.2). + /// + [Fact] + public void Reconnect_ReDeclaresSubscription_AfterWorldEntry_EvenWhilePanelClosed() + { + var calls = new List(); + 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) ──────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs index c53a8fe3..c62fcf16 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs @@ -276,6 +276,134 @@ public sealed class SocialPanelLiveMountProbeTests // fail this test, not just the log. 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(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(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 // string resolver renders blank captions even though the layout mounts). foreach (UiTabTableEntry t in tabs.Tabs)