feat(ui): FA5 -- allegiance page fully live: CF-1 subscription, blocks, roster, swear/break/kick

Campaign FA slice FA5. The Allegiance page (0x10000291) goes from FA3's
empty-state shell to fully live, wired against FA1's parser and FA2's
RuntimeAllegianceState/IRuntimeAllegianceCommands (both already shipped
the full command surface, including SetUpdateSubscription).

CF-1 (the corrected data subscription): 0x001F AllegianceUpdateRequest,
not 0x027B, is the panel's data source (0x027B/0x027C are text-only chat
per FA2 MF-2). Wired at retail's three arming points -- Bind's PostInit
attempt (almost always a pre-world no-op), the post-world EnteredWorld
seam (RedeclareAfterWorldEntry, UNCONDITIONAL -- does not check the
current latch, matching retail's own PlayerDescReceived arm and avoiding
the exact MF-3-REOPEN bug class FA4 hit for 0x00A6), and the visible
branch (SetPageVisible, edge-triggered, folded into
SocialPanelController's existing window-shown+active-tab conjunction
alongside Fellowship's 0x00A6).

Monarch/patron/self blocks: per-relationship empty-state gate (fix-round
SF-7) replacing FA3's coarse HasProfile-only gate -- the monarch block
hides when there is no monarch OR the monarch is the viewer; the patron
block hides when there is no patron OR the patron is the monarch (in
which case the monarch block's 0x10000490 sub-block reveals and its
label swaps to PatronSlashMonarchLabel). Field sources decompiled fresh
from gmAllegianceUI::UpdatePlayerData/UpdateMonarchData/UpdatePatronData:
0x10000251 is the ALLEGIANCE's own name (not the viewer's), follower
counts are TotalVassals/TotalMembers-1 directly off the wire, and the
"experience passed up" text (0x10000492, doubled -- scoped FindDescendant
under each of its two parents) is the viewer's own CpTithed under the
monarch/patron blocks and each vassal's own CpTithed in their row.

Vassal roster: flat list built via UiTemplateListBox.FlushPreservingScroll
in the FA4 roster-diff pattern (guid-set diff, in-place update on an
unchanged set), rendering in the bindings' own already-reversed order.

Swear/break/kick: each opens a local confirmation dialog
(RetailDialogFactory via ShowConfirmation) before sending, mirroring
retail's MakeSwearConfirmationDialog family -- Swear targets the WORLD
selection (via the same ClientObjectTable name resolver
ToolbarRuntimeBindings.ResolveName already uses), Break targets the
current patron, Kick targets the panel-local selected vassal row (no
world-selection sync for Allegiance, unlike Fellowship). The
server-driven "accept incoming swear" (ConfirmationType 1) needed no new
code -- GameplayConfirmationController already handles every type
generically; a new test verifies it explicitly.

Runtime/composition plumbing: DeferredGameRuntimeStateCommands gains
Allegiance{Swear,Break,Kick,SetUpdateSubscription}; SocialRuntimeBindings
gains the Allegiance view/command projections; SocialPanelController.
Callbacks.AllegianceSnapshot widens to a full
SocialAllegiancePageController.Bindings record, mirroring FA4's
Fellowship widening.

Tests: SocialPanelControllerTests.cs gains 10 tests covering the SF-7
gate (4), roster population, swear/break/kick wiring (3), and the CF-1
subscription arming points (2); GameplayConfirmationControllerTests.cs
gains the type-1 verification test.
Also extends SocialPanelLiveMountProbeTests.cs (production-mount
assertions: scoped 0x10000492 resolution, the vassal row template, the
checkbox, confirmation-dialog string resolution, and a full production
Bind() pass) -- not yet run against live DATs in this worktree (no
Documents/Asheron's Call present here).

Release build green; full solution suite 13,296 passed / 4 skipped / 0
failed (13,300 total), up from FA4's 13,285/4/0 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 08:40:07 +02:00
parent f5bd3e5621
commit 7ed79eaf10
8 changed files with 1412 additions and 95 deletions

View file

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

View file

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

View file

@ -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<UiTemplateListBox>(vassalListBoxEl);
Console.WriteLine(
$"[socialprobe] allegiance ListBox 0x10000260 templates={vassalListBox.Templates.Count} "
+ $"scrollbar=0x{vassalListBox.ScrollbarElementId:X8}");
Assert.NotEmpty(vassalListBox.Templates);
UiTemplateListEntry vassalRowTemplate = vassalListBox.Templates[0];
UiElement? vassalRow = rowTemplates.Resolve(
vassalRowTemplate.TemplateLayoutId, vassalRowTemplate.TemplateElementId);
Console.WriteLine(
$"[socialprobe] allegiance row template 0x{vassalRowTemplate.TemplateLayoutId:X8}/"
+ $"0x{vassalRowTemplate.TemplateElementId:X8} -> {(vassalRow is null ? "IMPORT NULL" : vassalRow.GetType().Name)}");
Assert.NotNull(vassalRow);
foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
{
(0x10000268u, "VassalName", typeof(UiText)),
(0x10000269u, "VassalExperiencePassedUp", typeof(UiText)),
})
{
UiElement? el = UiElement.FindDescendant(vassalRow!, id);
Console.WriteLine($"[socialprobe] allegiance row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
Assert.IsType(expectedType, el);
}
UiElement? offlineMarker = UiElement.FindDescendant(vassalRow!, 0x100004AAu);
Console.WriteLine($"[socialprobe] allegiance row field OfflineMarker 0x100004AA -> {(offlineMarker is null ? "MISSING" : offlineMarker.GetType().Name)}");
Assert.NotNull(offlineMarker);
// The IgnoreAllegianceRequests checkbox (D7: dimmed on the Options
// Character tab, but still a live read/write toggle here — same
// shape as the fellowship page's four checkboxes).
UiElement? ignoreRequestsCheckbox = UiElement.FindDescendant(tabs, 0x10000262u);
Console.WriteLine(
$"[socialprobe] allegiance checkbox IgnoreAllegianceRequests 0x10000262 -> "
+ $"{(ignoreRequestsCheckbox is null ? "MISSING" : ignoreRequestsCheckbox.GetType().Name)}");
Assert.IsType<UiButton>(ignoreRequestsCheckbox);
// CF-1 + AD-85: the checkbox label/tooltip (0x23000003) and the four
// confirmation-dialog templates + two label captions (0x23000001) —
// resolved verbatim, never substituted (class doc).
string? ignoreRequestsLabel = strings.Resolve(
0x23000003u, DatStringResolver.ComputeHash("ID_PlayerOption_IgnoreAllegianceRequests"));
Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_IgnoreAllegianceRequests -> '{ignoreRequestsLabel}'");
Assert.False(string.IsNullOrEmpty(ignoreRequestsLabel));
foreach (string key in new[]
{
"ID_Allegiance_MonarchLabel",
"ID_Allegiance_PatronSlashMonarchLabel",
"ID_Allegiance_SwearConfirmation",
"ID_Allegiance_BreakConfirmation",
"ID_Allegiance_KickConfirmation",
})
{
string? value = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
Console.WriteLine($"[socialprobe] allegiance string {key} -> '{value}'");
Assert.False(string.IsNullOrEmpty(value));
}
// Full end-to-end Bind against the PRODUCTION mount (mirrors the
// fellowship Bind check above) — proves Bind() finds every element
// it needs with zero "not found" console warnings.
UiElement? allegiancePageForBind = UiElement.FindDescendant(tabs, 0x10000291u);
Assert.NotNull(allegiancePageForBind);
var allegianceOriginalOut = Console.Out;
var allegianceCapture = new StringWriter();
Console.SetOut(allegianceCapture);
SocialAllegiancePageController? allegianceController;
try
{
allegianceController = SocialAllegiancePageController.Bind(
allegiancePageForBind!,
new SocialAllegiancePageController.Bindings(
Snapshot: () => new RuntimeAllegianceSnapshot(),
Monarch: () => null,
Patron: _ => null,
Member: _ => null,
Vassals: _ => [],
Swear: _ => default,
Break: _ => default,
Kick: _ => default,
SetUpdateSubscription: _ => default,
Selection: new AcDream.Core.Selection.SelectionState(),
LocalPlayerGuid: () => 0u,
CurrentCharacterOption: _ => false,
SetCharacterOption: (_, _) => { },
TemplateResolver: rowTemplates.Resolve,
ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
ResolveWorldObjectName: _ => null,
ShowConfirmation: (_, _) => 0u));
}
finally
{
Console.SetOut(allegianceOriginalOut);
}
string allegianceBindLog = allegianceCapture.ToString();
Console.WriteLine($"[socialprobe] allegiance Bind() console output:\n{allegianceBindLog}");
Assert.NotNull(allegianceController);
Assert.DoesNotContain("not found", allegianceBindLog);
// Tab button captions — non-empty (the #375 resolver class: a missing
// string resolver renders blank captions even though the layout mounts).
foreach (UiTabTableEntry t in tabs.Tabs)