feat(ui): FA3 -- mount the social panel shell (Friends/Allegiance/Fellowship/Squelch)

Campaign FA slice FA3: retail's four-tab social panel (LayoutDesc
0x2100006E slot 0x1000018F, RetailPanelCatalog id 12), built on the OP3
OptionsPanelController recipe -- Type-8 tab host, ActivateTabBehavior,
per-page scoped controllers.

- SocialPanelController mounts the tab host and wires the close button;
  F3 (ToggleAllegiancePanel) and F4 (ToggleFellowshipPanel) open the
  panel and switch to their own tab, sharing the same gmPanelUI
  one-active-panel exclusivity every sibling main panel already has.
- The live-DAT tab table CORRECTS the coordinator addendum's x-order
  guess: button 0x1000028C ("Allegiance") pairs with page 0x10000291 and
  is the authored DEFAULT entry, not Friends -- each button's own page
  id and its own P0x57 (matching the F3/F4 ActionMap ids on the
  Allegiance/Fellowship pages specifically) both corroborate the real
  pairing. See SocialPanelController's class doc for the full table.
- SocialFellowshipPageController swaps the two authored empty/full
  frames (0x1000026B/0x10000275) on RuntimeFellowshipState's
  IsInFellowship -- both frames' full containment (name box, create
  button, checkboxes vs. roster list, six buttons) was confirmed by the
  live-mount probe, so a single Visible toggle per frame is the whole
  swap (closes lane-A unknown U6).
- SocialAllegiancePageController hides the monarch/patron blocks and
  blanks their name text to a literal space when
  RuntimeAllegianceState.Snapshot.HasProfile is false, using SCOPED
  FindDescendant lookups (the panel authors 0x10000492 twice, once per
  block).
- SocialFriendsPageController/SocialSquelchPageController bind their
  ListBoxes read-only to RuntimeCommunicationState's existing J4.1
  Friends/Squelch owners (names only), rebuilding on revision change.
  Their action buttons are honest INERT (D1) -- register row AD-79.
- UiTemplateListBox gains Flush() (lane A/D's "Gap found" prerequisite)
  so a poll-and-rebuild list can shrink between refreshes.
- RetailPanelCatalog.SocialPanel = 12, byte-verified from the live slot's
  own P0x10000029; listed in Mounted only (no toolbar button -- lane A
  §6.1: the open path is keyboard-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 02:58:09 +02:00
parent b560f415cd
commit 0a9ca2f1f9
12 changed files with 854 additions and 1 deletions

View file

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
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).
///
/// <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.
/// </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).
/// </para>
/// </summary>
public sealed class SocialAllegiancePageController
{
private static readonly IReadOnlyList<UiText.Line> BlankLine =
[new UiText.Line(" ", System.Numerics.Vector4.One)];
private static readonly IReadOnlyList<UiText.Line> NoLines = [];
private readonly UiElement _monarchField;
private readonly UiElement _patronField;
private readonly UiText? _monarchName;
private readonly UiText? _patronName;
private readonly Func<RuntimeAllegianceSnapshot> _snapshot;
private SocialAllegiancePageController(
UiElement monarchField,
UiElement patronField,
UiText? monarchName,
UiText? patronName,
Func<RuntimeAllegianceSnapshot> snapshot)
{
_monarchField = monarchField;
_patronField = patronField;
_monarchName = monarchName;
_patronName = patronName;
_snapshot = snapshot;
}
public static SocialAllegiancePageController? Bind(
UiElement pageRoot,
Func<RuntimeAllegianceSnapshot> snapshot)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot);
if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field "
+ "containers (0x10000255/0x1000025A) not found — allegiance page "
+ "will not present its empty state.");
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.");
var controller = new SocialAllegiancePageController(
monarchField, patronField, monarchName, patronName, snapshot);
controller.Tick();
return controller;
}
/// <summary>
/// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
/// — called every frame from <see cref="SocialPanelController.Tick"/> (same
/// reasoning as <see cref="SocialFellowshipPageController.Tick"/>).
/// </summary>
public void Tick()
{
bool hasProfile = _snapshot().HasProfile;
_monarchField.Visible = hasProfile;
_patronField.Visible = hasProfile;
IReadOnlyList<UiText.Line> lines = hasProfile ? NoLines : BlankLine;
if (_monarchName is not null) _monarchName.LinesProvider = () => lines;
if (_patronName is not null) _patronName.LinesProvider = () => lines;
}
}