diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 5496a785..cc85f4c7 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -663,34 +663,35 @@ public sealed class RetailUiRuntime : IDisposable
}
///
- /// Campaign FA slice FA4, D6: a fellowship invite (ConfirmationType.
- /// Fellowship, 4) is intercepted BEFORE it ever reaches the generic
- /// dialog — retail's
- /// Fellowship.cs:121-equivalent client-side mirror:
- /// IgnoreFellowshipRequests auto-declines, FellowshipAutoAcceptRequests
- /// auto-accepts, and the two are mutually exclusive
- /// (RuntimeCharacterState.TrySetOption, already ported) so at
- /// most one fires. Neither bit set → falls through to the generic
- /// controller exactly as it already did before this slice (it already
- /// matches the constants — see D6/lane A §5.3).
+ /// MUST-FIX 2 (FA4 fix round, 2026-08-12): every server confirmation
+ /// request — including a fellowship invite
+ /// (ConfirmationType.Fellowship, 4) — reaches the generic
+ /// dialog unconditionally,
+ /// exactly like retail. This slice originally shipped a client-side
+ /// interceptor here (D6) that auto-declined/auto-accepted based on
+ /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
+ /// before the dialog. Byte-verified across
+ /// Handle_Character__ConfirmationRequest @0x005640A0 (a bare
+ /// 7-way jump table; case 4 is one call, no option read),
+ /// RecvNotice_FellowshipRequest @0x00490880 (copies the string,
+ /// tail-calls the dialog maker, no option read), and
+ /// MakeFellowRequestDialog @0x00490620 (its ONLY guard is
+ /// m_fellowRequestContext) — plus a whole-file sweep of both
+ /// accessors — retail's client reads NEITHER option bit on ANY
+ /// confirmation path. ACE filters both bits SERVER-SIDE
+ /// (Player_Fellowship.cs:98-102 refuses the recruiter outright;
+ /// Fellowship.cs:121 auto-joins without ever sending a type-4
+ /// request) — so the interceptor was either dead code (against a
+ /// correct ACE, which never sends the request the interceptor would
+ /// act on) or actively harmful (against a drifting/misconfigured
+ /// server, where IgnoreFellowshipRequests's client default of
+ /// true would silently swallow every real invite with no dialog
+ /// and no chat line). Deleted outright — see
+ /// docs/plans/2026-08-11-fellowship-allegiance-campaign.md D6's
+ /// correction and register row AD-78's fix-round addendum.
///
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
- {
- if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship
- && TryAutoRespondToFellowshipInvite(request))
- return true;
- return _gameplayConfirmationController?.HandleRequest(request) == true;
- }
-
- private bool TryAutoRespondToFellowshipInvite(GameEvents.CharacterConfirmationRequest request)
- {
- bool ignore = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.IgnoreFellowshipRequests);
- bool autoAccept = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.FellowshipAutoAcceptRequests);
- if (!ignore && !autoAccept) return false;
-
- _bindings.Confirmations.SendResponse(request.Type, request.ContextId, autoAccept);
- return true;
- }
+ => _gameplayConfirmationController?.HandleRequest(request) == true;
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
index f45ce004..ca1001e2 100644
--- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
@@ -33,6 +33,45 @@ public sealed class GameplayConfirmationControllerTests
Assert.Equal(0u, controller.ActiveDialogContext);
}
+ ///
+ /// MUST-FIX 2 (FA4 fix round, 2026-08-12): a fellowship invite
+ /// (ConfirmationType.Fellowship, type 4) reaches this generic
+ /// controller and opens a dialog exactly like any other type — no
+ /// client-side interceptor exists anymore (D6's correction: retail's
+ /// client reads neither option bit on the invite path;
+ /// RetailUiRuntime.HandleConfirmationRequest now routes every
+ /// type, including 4, straight here). Type 4 is NOT in the
+ /// 2/3/5/6 " Continue?"-suffix set, so the message renders verbatim —
+ /// matching Handle_Character__ConfirmationRequest @0x005640A0's
+ /// case-4 arm, which is a single call with no text transformation.
+ ///
+ [Fact]
+ public void FellowshipInviteRequest_Type4_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(4u, 7u, "Alice invites you to join their fellowship.")));
+
+ Assert.Equal(
+ "Alice invites you to join their fellowship.",
+ string.Join(" ", Assert.IsType(shown!.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
+
+ Assert.IsType(shown.FindElement(
+ RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
+
+ Assert.Equal([(4u, 7u, true)], responses);
+ Assert.Equal(0u, controller.ActiveDialogContext);
+ }
+
[Fact]
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
{