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() {