From 836fa25da893c3e1079c298c4e1dafe758f2d61c Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 19:58:21 -0700 Subject: [PATCH 1/2] fix(NPC): hide locked dealer conversations --- .../Entities/DealerLifecyclePolicyTests.cs | 37 +++++++ S1API/Entities/NPC.cs | 1 + S1API/Entities/NPCDealer.cs | 102 +++++++++++++++++- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs index 4ee25eee..1ec32d12 100644 --- a/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs +++ b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs @@ -55,6 +55,43 @@ public void DealerHomeEventNeverAliasesAConsumerScheduleAction(string name, bool Assert.Equal(expected, NPCPrefabBuilder.IsDealerHomeEventName(name)); } + [Theory] + [InlineData(false, true, 0, 0, 0, true)] + [InlineData(false, false, 0, 0, 0, false)] + [InlineData(true, true, 0, 0, 0, false)] + [InlineData(false, true, 1, 0, 0, false)] + [InlineData(false, true, 0, 1, 0, false)] + [InlineData(false, true, 0, 0, 1, false)] + public void OnlyCreatedEmptyLockedDealerConversationsAreHidden( + bool relationshipUnlocked, + bool uiCreated, + int messageCount, + int messageChainCount, + int responseCount, + bool expected) + { + Assert.Equal( + expected, + NPCDealer.ShouldHideLockedConversation( + relationshipUnlocked, + uiCreated, + messageCount, + messageChainCount, + responseCount)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void DealerConversationUiIsCreatedOnlyForUnlockedRelationships( + bool relationshipUnlocked, + bool expected) + { + Assert.Equal( + expected, + NPCDealer.ShouldEnsureConversationUi(relationshipUnlocked)); + } + [Fact] public void ConnectionIdsAreStableAcrossSpawnOrderReconciliation() { diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 14afd84a..fb64e814 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -5014,6 +5014,7 @@ internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); CleanupAwarenessEventHooks(); + _dealer?.Cleanup(); _messaging?.Cleanup(); CleanupVehicleLifecycleHooks(); } diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index 4f482fbd..a158e4c8 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -68,6 +68,7 @@ public sealed class NPCDealer private readonly ManagedEventRegistrationTracker _dealerRecruitedHandlers = new ManagedEventRegistrationTracker(); private Action? _contractAcceptedHandlers; private bool _contractAcceptedHooked; + private Action? _relationshipUnlockedHandler; internal NPCDealer(NPC npc) { @@ -138,10 +139,16 @@ private void EnsureDealerCategory() { try { - NPC.SetConversationCategory(S1Messaging.EConversationCategory.Dealer); + bool isUnlocked = NPC.Relationship.IsUnlocked; + NPC.SetConversationCategory( + S1Messaging.EConversationCategory.Dealer, + ensureUi: ShouldEnsureConversationUi(isUnlocked)); + EnsureRelationshipUnlockHook(); + if (NPC.S1NPC.MSGConversation != null) { TryHookConversationUIRefresh(NPC.S1NPC.MSGConversation); + HideLockedEmptyConversation(); RefreshDealerCategoryBadge(); } } @@ -176,6 +183,7 @@ private void TryHookConversationUIRefresh(object convoObj) convo.onLoaded = new System.Action(() => { try { prevLoaded?.Invoke(); } catch { } + HideLockedEmptyConversation(); RefreshDealerCategoryBadge(); }); @@ -193,6 +201,98 @@ private void TryHookConversationUIRefresh(object convoObj) } } + private void EnsureRelationshipUnlockHook() + { + _relationshipUnlockedHandler ??= (_, _) => ShowConversationAfterUnlock(); + + NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; + NPC.Relationship.OnUnlocked += _relationshipUnlockedHandler; + } + + private void ShowConversationAfterUnlock() + { + try + { + NPC.SetConversationCategory( + S1Messaging.EConversationCategory.Dealer, + ensureUi: true); + + var conversation = NPC.S1NPC.MSGConversation; + if (conversation == null) + return; + + conversation.SetIsKnown(true); + conversation.SetEntryVisibility(true); + TryHookConversationUIRefresh(conversation); + RefreshDealerCategoryBadge(); + } + catch (Exception ex) + { + Logger.Warning($"Exception showing dealer conversation after unlock for {NPC.ID}: {ex.Message}"); + } + } + + private void HideLockedEmptyConversation() + { + var conversation = NPC.S1NPC.MSGConversation; + if (conversation == null) + return; + + bool uiCreated = + Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "uiCreated") is bool created + && created; + int messageCount = conversation.messageHistory?.Count ?? 0; + int messageChainCount = conversation.messageChainHistory?.Count ?? 0; + int responseCount = conversation.currentResponses?.Count ?? 0; + if (!ShouldHideLockedConversation( + NPC.Relationship.IsUnlocked, + uiCreated, + messageCount, + messageChainCount, + responseCount)) + { + return; + } + + bool conversationCanBeHidden = NPC.ConversationCanBeHidden; + try + { + if (!conversationCanBeHidden) + NPC.ConversationCanBeHidden = true; + + conversation.SetEntryVisibility(false); + } + finally + { + if (!conversationCanBeHidden) + NPC.ConversationCanBeHidden = false; + } + } + + internal static bool ShouldHideLockedConversation( + bool relationshipUnlocked, + bool uiCreated, + int messageCount, + int messageChainCount, + int responseCount) => + !relationshipUnlocked + && uiCreated + && messageCount == 0 + && messageChainCount == 0 + && responseCount == 0; + + internal static bool ShouldEnsureConversationUi(bool relationshipUnlocked) => + relationshipUnlocked; + + internal void Cleanup() + { + if (_relationshipUnlockedHandler == null) + return; + + NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; + _relationshipUnlockedHandler = null; + } + /// /// Refresh the category badge on the existing conversation entry to ensure Dealer icon/label/color are shown. /// Mirrors MessagesApp.CreateConversationUI category setup. From 6aba7f9306a67a1eaa7be8d67bf977de181287ad Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 21:29:25 -0700 Subject: [PATCH 2/2] fix(NPC): make dealer conversation hooks idempotent --- S1API/Entities/NPCDealer.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index a158e4c8..01b7d894 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -69,6 +69,7 @@ public sealed class NPCDealer private Action? _contractAcceptedHandlers; private bool _contractAcceptedHooked; private Action? _relationshipUnlockedHandler; + private S1Messaging.MSGConversation? _conversationUiRefreshHooked; internal NPCDealer(NPC npc) { @@ -178,6 +179,9 @@ private void TryHookConversationUIRefresh(object convoObj) RefreshDealerCategoryBadge(); } + if (ReferenceEquals(_conversationUiRefreshHooked, convo)) + return; + // Hook onLoaded (called after UI is loaded from save) var prevLoaded = convo.onLoaded; convo.onLoaded = new System.Action(() => @@ -194,6 +198,8 @@ private void TryHookConversationUIRefresh(object convoObj) try { prevOpened?.Invoke(); } catch { } RefreshDealerCategoryBadge(); }); + + _conversationUiRefreshHooked = convo; } catch (Exception ex) { @@ -286,11 +292,13 @@ internal static bool ShouldEnsureConversationUi(bool relationshipUnlocked) => internal void Cleanup() { - if (_relationshipUnlockedHandler == null) - return; + if (_relationshipUnlockedHandler != null) + { + NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; + _relationshipUnlockedHandler = null; + } - NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; - _relationshipUnlockedHandler = null; + _conversationUiRefreshHooked = null; } ///