From 290f9b584aa032ae5efabd865aa9e8244e90ae33 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 07:32:41 +0200 Subject: [PATCH] fix #FA4-mechanism-MUST-FIX-2: delete the non-retail client-side fellowship-invite intercept RetailUiRuntime.TryAutoRespondToFellowshipInvite auto-declined/auto-accepted fellowship invites based on IgnoreFellowshipRequests/FellowshipAutoAcceptRequests before the dialog ever reached GameplayConfirmationController. Byte-verified across Handle_Character__ConfirmationRequest @0x005640A0, RecvNotice_FellowshipRequest @0x00490880, and MakeFellowRequestDialog @0x00490620 (whose only guard is m_fellowRequestContext) plus a whole-file sweep of both option accessors: retail's client reads neither bit on any confirmation path. ACE filters both bits server-side, so the interceptor was dead code against a correct ACE and actively harmful against a drifting one (IgnoreFellowshipRequests defaults true, so it would silently swallow real invites with no dialog and no chat line). HandleConfirmationRequest now routes every confirmation type, including 4, straight to the generic controller -- exactly like retail. No tests existed for the deleted interceptor (nothing to remove); added a test proving the type-4 dialog renders the server message verbatim (not "Continue?"-suffixed) and sends accept/decline through the generic path. Per the corrected plan D6 (docs/plans/2026-08-11-fellowship-allegiance-campaign.md). Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/RetailUiRuntime.cs | 53 ++++++++++--------- .../UI/GameplayConfirmationControllerTests.cs | 39 ++++++++++++++ 2 files changed, 66 insertions(+), 26 deletions(-) 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() {