feat(ui): FA5 -- allegiance page fully live: CF-1 subscription, blocks, roster, swear/break/kick
Campaign FA slice FA5. The Allegiance page (0x10000291) goes from FA3's
empty-state shell to fully live, wired against FA1's parser and FA2's
RuntimeAllegianceState/IRuntimeAllegianceCommands (both already shipped
the full command surface, including SetUpdateSubscription).
CF-1 (the corrected data subscription): 0x001F AllegianceUpdateRequest,
not 0x027B, is the panel's data source (0x027B/0x027C are text-only chat
per FA2 MF-2). Wired at retail's three arming points -- Bind's PostInit
attempt (almost always a pre-world no-op), the post-world EnteredWorld
seam (RedeclareAfterWorldEntry, UNCONDITIONAL -- does not check the
current latch, matching retail's own PlayerDescReceived arm and avoiding
the exact MF-3-REOPEN bug class FA4 hit for 0x00A6), and the visible
branch (SetPageVisible, edge-triggered, folded into
SocialPanelController's existing window-shown+active-tab conjunction
alongside Fellowship's 0x00A6).
Monarch/patron/self blocks: per-relationship empty-state gate (fix-round
SF-7) replacing FA3's coarse HasProfile-only gate -- the monarch block
hides when there is no monarch OR the monarch is the viewer; the patron
block hides when there is no patron OR the patron is the monarch (in
which case the monarch block's 0x10000490 sub-block reveals and its
label swaps to PatronSlashMonarchLabel). Field sources decompiled fresh
from gmAllegianceUI::UpdatePlayerData/UpdateMonarchData/UpdatePatronData:
0x10000251 is the ALLEGIANCE's own name (not the viewer's), follower
counts are TotalVassals/TotalMembers-1 directly off the wire, and the
"experience passed up" text (0x10000492, doubled -- scoped FindDescendant
under each of its two parents) is the viewer's own CpTithed under the
monarch/patron blocks and each vassal's own CpTithed in their row.
Vassal roster: flat list built via UiTemplateListBox.FlushPreservingScroll
in the FA4 roster-diff pattern (guid-set diff, in-place update on an
unchanged set), rendering in the bindings' own already-reversed order.
Swear/break/kick: each opens a local confirmation dialog
(RetailDialogFactory via ShowConfirmation) before sending, mirroring
retail's MakeSwearConfirmationDialog family -- Swear targets the WORLD
selection (via the same ClientObjectTable name resolver
ToolbarRuntimeBindings.ResolveName already uses), Break targets the
current patron, Kick targets the panel-local selected vassal row (no
world-selection sync for Allegiance, unlike Fellowship). The
server-driven "accept incoming swear" (ConfirmationType 1) needed no new
code -- GameplayConfirmationController already handles every type
generically; a new test verifies it explicitly.
Runtime/composition plumbing: DeferredGameRuntimeStateCommands gains
Allegiance{Swear,Break,Kick,SetUpdateSubscription}; SocialRuntimeBindings
gains the Allegiance view/command projections; SocialPanelController.
Callbacks.AllegianceSnapshot widens to a full
SocialAllegiancePageController.Bindings record, mirroring FA4's
Fellowship widening.
Tests: SocialPanelControllerTests.cs gains 10 tests covering the SF-7
gate (4), roster population, swear/break/kick wiring (3), and the CF-1
subscription arming points (2); GameplayConfirmationControllerTests.cs
gains the type-1 verification test.
Also extends SocialPanelLiveMountProbeTests.cs (production-mount
assertions: scoped 0x10000492 resolution, the vassal row template, the
checkbox, confirmation-dialog string resolution, and a full production
Bind() pass) -- not yet run against live DATs in this worktree (no
Documents/Asheron's Call present here).
Release build green; full solution suite 13,296 passed / 4 skipped / 0
failed (13,300 total), up from FA4's 13,285/4/0 baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f5bd3e5621
commit
7ed79eaf10
8 changed files with 1412 additions and 95 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,29 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
|
||||
generation, panelOpen));
|
||||
|
||||
// ── Campaign FA slice FA5: allegiance page commands ─────────────────
|
||||
// Same shape as the fellowship block above.
|
||||
|
||||
public RuntimeCommandResult AllegianceSwear(uint patronGuid) =>
|
||||
Invoke((commands, generation) => commands.Allegiance.Swear(
|
||||
generation, patronGuid));
|
||||
|
||||
public RuntimeCommandResult AllegianceBreak(uint targetGuid) =>
|
||||
Invoke((commands, generation) => commands.Allegiance.Break(
|
||||
generation, targetGuid));
|
||||
|
||||
public RuntimeCommandResult AllegianceKick(uint vassalGuid) =>
|
||||
Invoke((commands, generation) => commands.Allegiance.Kick(
|
||||
generation, vassalGuid));
|
||||
|
||||
/// <summary><c>0x001F</c> — CF-1's data subscription toggle (the
|
||||
/// allegiance-page analogue of <see cref="FellowshipSetPanelOpen"/>'s
|
||||
/// <c>0x00A6</c>). See <see cref="AcDream.App.UI.Layout.SocialAllegiancePageController"/>
|
||||
/// for the three retail arming points this forwards.</summary>
|
||||
public RuntimeCommandResult AllegianceSetUpdateSubscription(bool on) =>
|
||||
Invoke((commands, generation) => commands.Allegiance.SetUpdateSubscription(
|
||||
generation, on));
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
lock (_gate)
|
||||
|
|
|
|||
|
|
@ -1,137 +1,782 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3: the Allegiance page's empty-state presentation —
|
||||
/// the ONLY behavior this SHELL slice owns for this page (live monarch/
|
||||
/// patron/vassal population, swear/break/kick + confirmations, and the
|
||||
/// per-member online-state dimming are FA5 scope).
|
||||
/// Campaign FA slice FA5: the Allegiance page fully live — the <c>0x001F</c>
|
||||
/// data subscription (CF-1), monarch/patron/self blocks, the flat vassal
|
||||
/// list, swear/break/kick + their local confirmation dialogs, and the
|
||||
/// per-relationship empty-state gate FA3's fix round flagged as owed
|
||||
/// (mechanism SF-7). FA3 shipped only the coarse
|
||||
/// <see cref="RuntimeAllegianceSnapshot.HasProfile"/> gate — see that
|
||||
/// slice's own class doc, now superseded by this one.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it
|
||||
/// hides the monarch/patron blocks and blanks their name text to a literal
|
||||
/// space per-block, gated on whether each relationship exists
|
||||
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract
|
||||
/// simplifies this to the single gate this campaign's Runtime owner
|
||||
/// actually exposes today: <see cref="RuntimeAllegianceSnapshot.HasProfile"/>
|
||||
/// — no allegiance push has landed this generation. When a profile HAS
|
||||
/// arrived, this controller leaves both blocks visible with their
|
||||
/// build-time (empty) text rather than fabricate monarch/patron content —
|
||||
/// that population is FA5's job.
|
||||
/// <b>CF-1 — the data subscription.</b>
|
||||
/// <c>docs/research/2026-08-11-fa-allegiance-wire.md</c> §1.2 pins THREE
|
||||
/// retail arming points for <c>CM_Allegiance::Event_UpdateRequest(u32)</c>
|
||||
/// (opcode <c>0x001F</c>), all sending <c>1</c> except the last:
|
||||
/// <c>gmAllegianceUI::PostInit @0x004911C6</c>, <c>RecvNotice_PlayerDescReceived
|
||||
/// @0x00490D40</c>, and <c>OnVisibilityChanged</c>'s visible/hidden branches
|
||||
/// (<c>@0x004912DD</c>/<c>@0x00491311</c>). <see cref="Bind"/> attempts the
|
||||
/// PostInit arm (almost always a pre-world no-op in this campaign's
|
||||
/// process-lifetime mount — see <see cref="RedeclareAfterWorldEntry"/>'s own
|
||||
/// doc for why that is not a gap); <see cref="RedeclareAfterWorldEntry"/> is
|
||||
/// the PlayerDescReceived arm, wired to the LiveSession <c>EnteredWorld</c>
|
||||
/// seam exactly like FA4's <c>0x00A6</c>; <see cref="SetPageVisible"/> is the
|
||||
/// visible-branch arm, called by <see cref="SocialPanelController"/> on every
|
||||
/// "window shown AND Allegiance active" transition. Without this,
|
||||
/// <c>0x0020 AllegianceUpdate</c> never arrives on demand (lane C §2 row 4/5)
|
||||
/// and the panel shows nothing — <c>0x027B AllegianceInfoResponse</c> is
|
||||
/// text-only chat (FA2 MF-2's correction) and is NOT wired here.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Element ids confirmed by the FA3 live-mount probe:
|
||||
/// <c>0x10000255</c> monarch-field container, <c>0x1000025A</c>
|
||||
/// patron-field container, <c>0x10000257</c> monarch-name text (inside the
|
||||
/// monarch field), <c>0x1000025C</c> patron-name text (inside the patron
|
||||
/// field). Both lookups are SCOPED to the allegiance page root — the panel
|
||||
/// also authors <c>0x10000492</c> (the XP-passed-up text) TWICE, once under
|
||||
/// each block, so a flat lookup anywhere in this subsystem would risk
|
||||
/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP
|
||||
/// <c>0x10000211</c>-in-two-layouts lesson).
|
||||
/// <b>The FA4 MF-3-REOPEN lesson, applied here.</b> A reconnect's generation
|
||||
/// reset runs BEFORE the new session is in world, where every Runtime
|
||||
/// command is world-gated (<c>Inactive</c>, nothing sent).
|
||||
/// <see cref="ResetPageVisibleLatch"/> (wired to the pre-world reset seam)
|
||||
/// ONLY clears the local latch — it must never itself declare.
|
||||
/// <see cref="RedeclareAfterWorldEntry"/> (wired to the POST-world seam)
|
||||
/// does the actual send, and — unlike <see cref="SetPageVisible"/> — is
|
||||
/// UNCONDITIONAL: it does not compare against the current latch first,
|
||||
/// because retail's own <c>RecvNotice_PlayerDescReceived</c> arm does not
|
||||
/// check current visibility either (§1.2's table: it always sends <c>1</c>).
|
||||
/// The latch still advances "only on Accepted" (FA4's re-fix rule), so a
|
||||
/// dropped/rejected send leaves the latch clear for a later retry rather
|
||||
/// than lying about what was actually published.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>FA5 scope note (fix-round mechanism SF-7).</b> This shell's gate is
|
||||
/// coarser than retail's: <c>gmAllegianceUI::UpdateMonarchData
|
||||
/// @0x00491B40</c> hides the monarch block when there is no monarch OR the
|
||||
/// monarch IS the viewer, and hides the patron block on the analogous
|
||||
/// per-relationship test — this controller instead gates BOTH blocks on
|
||||
/// the single <see cref="RuntimeAllegianceSnapshot.HasProfile"/> flag. The
|
||||
/// plan's FA5 row records the two per-relationship acceptance lines FA5
|
||||
/// owes. Also note for whoever lands FA5: <see cref="Tick"/> reassigns
|
||||
/// <see cref="UiText.LinesProvider"/> UNCONDITIONALLY every frame — FA5's
|
||||
/// real monarch/patron name population must change this method at the
|
||||
/// same time, or its content will be overwritten on the very next frame.
|
||||
/// <b>Per-relationship empty-state gate (fix-round SF-7).</b>
|
||||
/// <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c> hides the monarch
|
||||
/// block when there is no monarch OR the monarch IS the viewer;
|
||||
/// <c>UpdatePatronData @0x004917C0</c> hides the patron block when there is
|
||||
/// no patron OR the patron IS the monarch (in which case the monarch block
|
||||
/// relabels via <c>ID_Allegiance_PatronSlashMonarchLabel</c> and reveals its
|
||||
/// <c>0x10000490</c> sub-block, byte-verified at the same two functions).
|
||||
/// Both hidden branches blank their name text to a literal single space —
|
||||
/// <c>UpdateMonarchData</c> ALSO blanks the monarch-followers text in its
|
||||
/// hidden branch (<c>@0x00491FCD</c>), a detail the coarser FA3 gate did not
|
||||
/// need to model.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Numeric-only text, no invented English (register row AD-85).</b> Every
|
||||
/// retail string site beyond a bare name or a variable-free caption
|
||||
/// (<c>ID_Allegiance_MonarchLabel</c>/<c>PatronSlashMonarchLabel</c>, which
|
||||
/// carry NO <c>StringInfo</c> variables and so resolve and render exactly as
|
||||
/// authored) needs <c>StringInfo</c> variable substitution acdream has not
|
||||
/// ported (the same gap AD-81 filed for the Fellowship page). This
|
||||
/// controller therefore renders followers/rank/experience-passed-up as bare
|
||||
/// numbers with no surrounding words — the retail-authored NUMBERS, never
|
||||
/// invented sentences — and the three local confirmation dialogs
|
||||
/// (Swear/Break/Kick) show retail's own unsubstituted template text
|
||||
/// verbatim when it resolves, falling back to the bare target name (also
|
||||
/// non-invented) when it does not.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Field sources, decompiled (`gmAllegianceUI::UpdatePlayerData
|
||||
/// @0x00491330`, <c>UpdateMonarchData</c>, <c>UpdatePatronData</c>).</b>
|
||||
/// <c>0x10000251</c> = the ALLEGIANCE's own name (<c>_allegiance.m_AllegianceName</c>
|
||||
/// — "CharacterName" in the id is a misnomer), not the viewer's own name.
|
||||
/// <c>0x10000252</c> ("your followers") = <c>_total_vassals</c> directly.
|
||||
/// <c>0x10000258</c> (monarch followers) = <c>_total_members - 1</c>. The
|
||||
/// self-rank field (<c>0x10000253</c>) queries a LIVE buffed quality
|
||||
/// (<c>CBaseQualities::InqInt(qualities, 0x1e)</c>, i.e.
|
||||
/// <c>PropertyInt.AllegianceRank</c>) plus <c>AllegianceData::GetTitle</c>'s
|
||||
/// 20-table title lookup — neither is ported here (the buffed/unbuffed
|
||||
/// distinction needs a Character-system seam this controller does not have,
|
||||
/// and the title table doesn't exist in acdream at all), so this controller
|
||||
/// substitutes the numerically-equivalent <see cref="RuntimeAllegianceSnapshot.Rank"/>
|
||||
/// (same <c>0x0020</c> message, same underlying stat in every observed case)
|
||||
/// rendered bare. The "experience passed up" text (<c>0x10000492</c>, doubled
|
||||
/// under <c>0x10000490</c> and the patron field) and the vassal row's own
|
||||
/// <c>0x10000269</c> both source <c>AllegianceData::_cp_tithed</c> — the
|
||||
/// VIEWER's own tithed amount under the monarch/patron blocks, the VASSAL's
|
||||
/// own tithed amount in each row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialAllegiancePageController
|
||||
{
|
||||
// ── Element ids (docs/research/2026-08-11-fa-panel-structure.md §3.2) ──
|
||||
private const uint SelfNameTextId = 0x10000251u;
|
||||
private const uint SelfFollowersTextId = 0x10000252u;
|
||||
private const uint SelfRankTextId = 0x10000253u;
|
||||
private const uint MonarchFieldId = 0x10000255u;
|
||||
private const uint MonarchLabelTextId = 0x10000256u;
|
||||
private const uint MonarchNameTextId = 0x10000257u;
|
||||
private const uint MonarchFollowersTextId = 0x10000258u;
|
||||
private const uint PatronFieldId = 0x1000025Au;
|
||||
private const uint PatronNameTextId = 0x1000025Cu;
|
||||
private const uint VassalListBoxId = 0x10000260u;
|
||||
private const uint IgnoreRequestsCheckboxId = 0x10000262u;
|
||||
private const uint SwearButtonId = 0x10000263u;
|
||||
private const uint BreakButtonId = 0x10000264u;
|
||||
private const uint KickButtonId = 0x10000265u;
|
||||
|
||||
/// <summary>Child of <see cref="MonarchFieldId"/> — shown only when the
|
||||
/// viewer's patron IS the monarch (SF-7).</summary>
|
||||
private const uint MonarchIsPatronSubBlockId = 0x10000490u;
|
||||
|
||||
/// <summary>Authored TWICE inside this page (§6 DISCIPLINE) — once under
|
||||
/// <see cref="MonarchIsPatronSubBlockId"/>, once directly under
|
||||
/// <see cref="PatronFieldId"/>. Every lookup MUST be scoped to the
|
||||
/// correct parent; a flat <c>FindDescendant</c> from the page root would
|
||||
/// non-deterministically pick one instance for both roles.</summary>
|
||||
private const uint ExperiencePassedUpTextId = 0x10000492u;
|
||||
|
||||
// ── Row-template element ids (same doc — the 3-part vassal row) ──
|
||||
private const uint RowNameTextId = 0x10000268u;
|
||||
private const uint RowExperiencePassedUpTextId = 0x10000269u;
|
||||
private const uint RowOfflineMarkerId = 0x100004AAu;
|
||||
|
||||
private const uint StringTableId = 0x23000001u;
|
||||
private const uint OptionStringTableId = 0x23000003u;
|
||||
|
||||
private static readonly Vector4 TextColor = Vector4.One;
|
||||
private static readonly Vector4 OfflineNameColor = new(0.6f, 0.6f, 0.6f, 1f);
|
||||
|
||||
private static readonly IReadOnlyList<UiText.Line> BlankLine =
|
||||
[new UiText.Line(" ", System.Numerics.Vector4.One)];
|
||||
|
||||
private static readonly IReadOnlyList<UiText.Line> NoLines = [];
|
||||
|
||||
/// <summary>
|
||||
/// Fix-round mechanism SF-2 / blast SF-4: hoisted <see cref="UiText.LinesProvider"/>
|
||||
/// delegates. <see cref="Tick"/> runs every frame regardless of panel
|
||||
/// visibility (see class doc); the original <c>() => lines</c> closure
|
||||
/// captured a local and allocated a display class PLUS a delegate on
|
||||
/// every call. These two cached delegates make the per-frame
|
||||
/// reassignment a plain field write — zero allocation while idle.
|
||||
/// </summary>
|
||||
[new UiText.Line(" ", TextColor)];
|
||||
private static readonly Func<IReadOnlyList<UiText.Line>> BlankLineProvider = () => BlankLine;
|
||||
private static readonly Func<IReadOnlyList<UiText.Line>> NoLinesProvider = () => NoLines;
|
||||
|
||||
/// <summary>Sentinel passed to <see cref="SetProvider"/> for the "hidden,
|
||||
/// blanked" state — deliberately NOT <see langword="null"/>, since the
|
||||
/// backing <c>_lastXxx</c> fields also start at <see langword="null"/>
|
||||
/// (uninitialized). Using <see langword="null"/> as both the sentinel
|
||||
/// AND the initial value would make the very first blank-state
|
||||
/// <see cref="Tick"/> a false "unchanged" no-op, leaving the widget at
|
||||
/// its import-time (non-blank) text instead of retail's literal single
|
||||
/// space.</summary>
|
||||
private const string BlankSentinel = " | ||||