From ce9dfc25a9e86a63cf59819a32f1d4c5c4e26488 Mon Sep 17 00:00:00 2001 From: Tick-git <62646477+Tick-git@users.noreply.github.com> Date: Sun, 29 Jun 2025 12:01:34 +0200 Subject: [PATCH] Refactor async quest management into modular system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for: #539 Resolved the issue in the quest cache system by introducing a removal list that's processed after each async tick on the map. And I started refactoring the async quest handling with the following goals in mind: - Removed reliance on static quest cache data. - Reorganized logic into non-static classes for better maintainability. - Added a QuestManagerAsync, modeled after RimWorld’s source code. - Abstracted the dependency on AsyncTimeComp via an interface — with the idea that time handling might eventually be tied to factions or players across maps. - Simplified and clarified some complex LINQ queries. - Aimed to make the code more expressive and easier to follow. - Added TODOs for items discovered during testing. Cheers --- Source/Client/AsyncTime/AsyncTimeComp.cs | 20 +- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 15 +- .../Client/AsyncTime/MultiplayerAsyncQuest.cs | 299 ------------------ .../Quests/AsyncQuestContextProviderByMap.cs | 52 +++ .../AsyncTime/Quests/IAsyncQuestContext.cs | 10 + .../Quests/IAsyncQuestContextProvider.cs | 10 + .../AsyncTime/Quests/QuestAsyncPatches.cs | 120 +++++++ .../AsyncTime/Quests/QuestManagerAsync.cs | 134 ++++++++ .../AsyncTime/Quests/TickableQuestCache.cs | 65 ++++ Source/Client/Multiplayer.cs | 3 + Source/Client/MultiplayerGame.cs | 2 + 11 files changed, 420 insertions(+), 310 deletions(-) delete mode 100644 Source/Client/AsyncTime/MultiplayerAsyncQuest.cs create mode 100644 Source/Client/AsyncTime/Quests/AsyncQuestContextProviderByMap.cs create mode 100644 Source/Client/AsyncTime/Quests/IAsyncQuestContext.cs create mode 100644 Source/Client/AsyncTime/Quests/IAsyncQuestContextProvider.cs create mode 100644 Source/Client/AsyncTime/Quests/QuestAsyncPatches.cs create mode 100644 Source/Client/AsyncTime/Quests/QuestManagerAsync.cs create mode 100644 Source/Client/AsyncTime/Quests/TickableQuestCache.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 837d28f1..ece8c98e 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -1,19 +1,19 @@ using HarmonyLib; +using Multiplayer.Client.Factions; +using Multiplayer.Client.Patches; +using Multiplayer.Client.Quests; +using Multiplayer.Client.Saving; +using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; using RimWorld.Planet; using System; using System.Collections.Generic; using Verse; -using Multiplayer.Client.Comp; -using Multiplayer.Client.Factions; -using Multiplayer.Client.Patches; -using Multiplayer.Client.Saving; -using Multiplayer.Client.Util; namespace Multiplayer.Client { - public class AsyncTimeComp : IExposable, ITickable + public class AsyncTimeComp : IExposable, ITickable, IAsyncQuestContext { public static Map tickingMap; public static Map executingCmdMap; @@ -429,7 +429,12 @@ public void QuestManagerTickAsyncTime() { if (!Multiplayer.GameComp.asyncTime || Paused) return; - MultiplayerAsyncQuest.TickMapQuests(this); + Multiplayer.QuestManagerAsync.TickQuestsAsync(this); + } + + public string GetQuestContextInfo() + { + return $"AsyncTimeComp for Map: {map.Index}; faction {map.ParentFaction}"; } } @@ -439,5 +444,4 @@ public enum DesignatorMode : byte MultiCell, Thing } - } diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 6859309e..5ddc6c3c 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using HarmonyLib; using Multiplayer.Client.Comp; using Multiplayer.Client.Desyncs; @@ -10,6 +7,9 @@ using Multiplayer.Common; using RimWorld; using RimWorld.Planet; +using System; +using System.Collections.Generic; +using System.Linq; using UnityEngine; using Verse; @@ -102,6 +102,7 @@ public void Tick() try { Find.TickManager.DoSingleTick(); + TickAsyncWorldQuests(); worldTicks++; Multiplayer.WorldComp.TickWorldSessions(); @@ -135,6 +136,14 @@ public void Tick() } } + private void TickAsyncWorldQuests() + { + if(Multiplayer.GameComp.asyncTime) + { + Multiplayer.QuestManagerAsync.TickWorldQuests(); + } + } + public void PreContext() { Find.TickManager.CurTimeSpeed = DesiredTimeSpeed; diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs deleted file mode 100644 index 883980f3..00000000 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ /dev/null @@ -1,299 +0,0 @@ -using HarmonyLib; -using Multiplayer.Common; -using RimWorld; -using RimWorld.Planet; -using RimWorld.QuestGen; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Verse; - -namespace Multiplayer.Client.Comp -{ - - [HarmonyPatch(typeof(SettlementAbandonUtility), nameof(SettlementAbandonUtility.Abandon))] - static class RemoveMapCacheOnAbandon - { - static void Prefix(MapParent settlement) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.asyncTime) return; - - var mapAsyncTimeComp = settlement.Map.AsyncTime(); - if (mapAsyncTimeComp != null) - { - MultiplayerAsyncQuest.TryRemoveCachedMap(mapAsyncTimeComp); - } - } - } - - [HarmonyPatch(typeof(QuestGen), nameof(QuestGen.Generate))] - static class CacheQuestAfterGeneration - { - static void Postfix(ref Quest __result) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.asyncTime) return; - - MultiplayerAsyncQuest.CacheQuest(__result); - } - } - - [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.QuestManagerTick))] - [HarmonyPriority(Priority.Last)] - static class DisableQuestManagerTickTest - { - static bool Prefix() - { - if (Multiplayer.Client == null) return true; - if (!Multiplayer.GameComp.asyncTime) return true; - - //Only tick world quest during world time - MultiplayerAsyncQuest.TickWorldQuests(); - return false; - } - } - - //Clear Cache then Cache all quests on game load - [HarmonyPatch(typeof(Game), nameof(Game.FinalizeInit))] - [HarmonyPriority(Priority.Last)] - static class SetupAsyncTimeLookupForQuests - { - static void Postfix() - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.asyncTime) return; - - MultiplayerAsyncQuest.Reset(); - - foreach (var quest in Find.QuestManager.QuestsListForReading) - { - MultiplayerAsyncQuest.CacheQuest(quest); - } - } - } - - [HarmonyPatch] - static class SetContextForQuest - { - static IEnumerable TargetMethods() - { - yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceAppeared)); - yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceAccepted)); - yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceCleanup)); - yield return AccessTools.Method(typeof(Quest), nameof(Quest.SetInitiallyAccepted)); - yield return AccessTools.Method(typeof(Quest), nameof(Quest.CleanupQuestParts)); - } - - static void Prefix(Quest __instance, ref AsyncTimeComp __state) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.asyncTime) return; - - __state = MultiplayerAsyncQuest.TryGetCachedQuestMap(__instance); - __state?.PreContext(); - } - - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); - } - - [HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] - static class SetContextForAccept - { - static void Prefix(Quest __instance, ref AsyncTimeComp __state) - { - if (Multiplayer.Client == null) return; - - //Make sure quest is accepted and async time is enabled and there are parts to this quest - if (__instance.State != QuestState.NotYetAccepted || !Multiplayer.GameComp.asyncTime || __instance.parts == null) return; - - __state = MultiplayerAsyncQuest.CacheQuest(__instance); - __state?.PreContext(); - } - - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); - } - - [HarmonyPatch(typeof(Quest), nameof(Quest.End))] - static class RemoveQuestFromCacheOnQuestEnd - { - static void Postfix(Quest __instance) - { - if (Multiplayer.Client == null) return; - MultiplayerAsyncQuest.TryRemoveCachedQuest(__instance); - } - } - - public static class MultiplayerAsyncQuest - { - //Probably should move this to a class so its not a Dictionary of Lists - private static readonly Dictionary> mapQuestsCache = new Dictionary>(); - private static readonly List worldQuestsCache = new List(); - - //List of quest parts that have mapParent as field - private static readonly List questPartsToCheck = new List() - { - typeof(QuestPart_DropPods), - typeof(QuestPart_SpawnThing), - typeof(QuestPart_PawnsArrive), - typeof(QuestPart_Incident), - typeof(QuestPart_RandomRaid), - typeof(QuestPart_ThreatsGenerator), - typeof(QuestPart_Infestation), - typeof(QuestPart_GameCondition), - typeof(QuestPart_JoinPlayer), - typeof(QuestPart_TrackWhenExitMentalState), - typeof(QuestPart_RequirementsToAcceptBedroom), - typeof(QuestPart_MechCluster), - typeof(QuestPart_DropMonumentMarkerCopy), - typeof(QuestPart_PawnsAvailable) - }; - - /// - /// Tries to remove Map from cache, then moves all Quests cached to that map to WorldQuestsCache - /// - /// Map to remove - /// If map is found in cache - public static bool TryRemoveCachedMap(AsyncTimeComp mapAsyncTimeComp) - { - if (mapQuestsCache.TryGetValue(mapAsyncTimeComp, out var quests)) - { - worldQuestsCache.AddRange(quests); - return mapQuestsCache.Remove(mapAsyncTimeComp); - } - - return false; - } - - /// - /// Tries to remove Quest from Map and World cache - /// - /// Quest to remove - /// If quest is found in cache - public static bool TryRemoveCachedQuest(Quest quest) - => mapQuestsCache.SingleOrDefault(x => x.Value.Contains(quest)).Value?.Remove(quest) ?? false | worldQuestsCache.Remove(quest); - - /// - /// Attempts to get the MapAsyncTimeComp cached for that quest - /// - /// Quest to find MapAsyncTimeComp for - /// MapAsyncTimeComp for that quest or Null if not found - public static AsyncTimeComp TryGetCachedQuestMap(Quest quest) - { - if (!Multiplayer.GameComp.asyncTime || quest == null) return null; - return mapQuestsCache.FirstOrDefault(x => x.Value.Contains(quest)).Key; - } - - /// - /// Determines if a quest has a MapAsyncTimeComp, if it does it will cache it to the mapQuestsCache otherwise to worldQuestsCache - /// - /// Quest to Cache - /// MapAsyncTimeComp for that quest or Null if not found - public static AsyncTimeComp CacheQuest(Quest quest) - { - if (!Multiplayer.GameComp.asyncTime || quest == null) return null; - - //Check if quest targets players map - var mapAsyncTimeComp = TryGetQuestMap(quest); - - //if it does add it to the cache for that map - if (mapAsyncTimeComp != null) - { - UpsertQuestMap(mapAsyncTimeComp, quest); - if (MpVersion.IsDebug) - Log.Message($"Info: Found AsyncTimeMap: '{mapAsyncTimeComp.map.Parent.Label}' for Quest: '{quest.name}'"); - } - //if it doesn't add it to the world quest list - else - { - worldQuestsCache.Add(quest); - if (MpVersion.IsDebug) - Log.Message($"Info: Could not find AsyncTimeMap for Quest: '{quest.name}'"); - } - - return mapAsyncTimeComp; - } - - /// - /// Clears all cache for both mapQuestsCache and worldQuestsCache as they are static and requires manually clearing on load - /// - public static void Reset() - { - mapQuestsCache.Clear(); - worldQuestsCache.Clear(); - } - - /// - /// Runs QuestTick() on all quests cached for worldQuestsCache - /// - public static void TickWorldQuests() - { - TickQuests(worldQuestsCache); - } - - /// - /// Runs QuestTick() on all quests cached for a specific map - /// - /// Map to tick Quests for - public static void TickMapQuests(AsyncTimeComp mapAsyncTimeComp) - { - if (mapQuestsCache.TryGetValue(mapAsyncTimeComp, out var quests)) - { - TickQuests(quests); - } - } - - /// - /// Runs QuestTick() on all quests passed - /// - /// Quests to run QuestTick() on - private static void TickQuests(IEnumerable quests) - { - foreach (var quest in quests) - { - quest.QuestTick(); - } - } - - /// - /// Attempts to find the map the quest will target, via its Quest Parts. Filtering on Map.IsPlayerHome and then check if it contains a MapAsyncTimeComp - /// - /// Quest to check Map Parts on - /// MapAsyncTimeComp for that quest or Null if not found - private static AsyncTimeComp TryGetQuestMap(Quest quest) - { - //Really terrible way to determine if any quest parts have a map which also has an async time - foreach (var part in quest.parts.Where(x => x != null && questPartsToCheck.Contains(x.GetType()))) - { - if (part.GetType().GetField("mapParent")?.GetValue(part) is MapParent { Map: not null } mapParent) - { - var mapAsyncTimeComp = mapParent.Map.IsPlayerHome ? mapParent.Map.AsyncTime() : null; - if (mapAsyncTimeComp != null) return mapAsyncTimeComp; - } - } - - return null; - } - - /// - /// Attempts to update or insert a new cache for a Map and Quest, will always attempt to remove the quest before adding in case it existed on another map. - /// - /// Map that will hold the quest - /// Quest for the map - private static void UpsertQuestMap(AsyncTimeComp mapAsyncTimeComp, Quest quest) - { - //if there is more then one map with the quest something went wrong - removes quest object in case it changes map - TryRemoveCachedQuest(quest); - - if (mapQuestsCache.TryGetValue(mapAsyncTimeComp, out var quests)) - { - quests.Add(quest); - } - else - { - mapQuestsCache[mapAsyncTimeComp] = new List { quest }; - } - } - } -} diff --git a/Source/Client/AsyncTime/Quests/AsyncQuestContextProviderByMap.cs b/Source/Client/AsyncTime/Quests/AsyncQuestContextProviderByMap.cs new file mode 100644 index 00000000..b79bd184 --- /dev/null +++ b/Source/Client/AsyncTime/Quests/AsyncQuestContextProviderByMap.cs @@ -0,0 +1,52 @@ +using Multiplayer.Client.Quests; +using RimWorld; +using RimWorld.Planet; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Multiplayer.Client.AsyncTime.Quests +{ + internal class AsyncQuestContextProviderByMap : IAsyncQuestContextProvider + { + //List of quest parts that have mapParent as field + private List questPartsToCheck = new List() + { + typeof(QuestPart_DropPods), + typeof(QuestPart_SpawnThing), + typeof(QuestPart_PawnsArrive), + typeof(QuestPart_Incident), + typeof(QuestPart_RandomRaid), + typeof(QuestPart_ThreatsGenerator), + typeof(QuestPart_Infestation), + typeof(QuestPart_GameCondition), + typeof(QuestPart_JoinPlayer), + typeof(QuestPart_TrackWhenExitMentalState), + typeof(QuestPart_RequirementsToAcceptBedroom), + typeof(QuestPart_MechCluster), + typeof(QuestPart_DropMonumentMarkerCopy), + typeof(QuestPart_PawnsAvailable) + }; + + public IAsyncQuestContext TryGetAsyncQuestContext(Quest quest) + { + //Really terrible way to determine if any quest parts have a map which also has an async time + foreach (var part in quest.parts.Where(x => x != null && questPartsToCheck.Contains(x.GetType()))) + { + if (part.GetType().GetField("mapParent")?.GetValue(part) is MapParent { Map: not null } mapParent) + { + // TODO: Issue with IsPlayerHome logic + // Example: Hospitality_Refugee quest (generated on client with dev tool) + // Problem: + // - When the quest is generated on the client, IsPlayerHome returns false, + // so the quest is incorrectly added to the world quest cache. + // - When the quest is accepted, it is correctly added to the async context. + var mapAsyncTimeComp = mapParent.Map.IsPlayerHome ? mapParent.Map.AsyncTime() : null; + if (mapAsyncTimeComp != null) return mapAsyncTimeComp; + } + } + + return null; + } + } +} diff --git a/Source/Client/AsyncTime/Quests/IAsyncQuestContext.cs b/Source/Client/AsyncTime/Quests/IAsyncQuestContext.cs new file mode 100644 index 00000000..b8d39fcc --- /dev/null +++ b/Source/Client/AsyncTime/Quests/IAsyncQuestContext.cs @@ -0,0 +1,10 @@ +namespace Multiplayer.Client.Quests +{ + public interface IAsyncQuestContext + { + void PreContext(); + void PostContext(); + + string GetQuestContextInfo(); + } +} diff --git a/Source/Client/AsyncTime/Quests/IAsyncQuestContextProvider.cs b/Source/Client/AsyncTime/Quests/IAsyncQuestContextProvider.cs new file mode 100644 index 00000000..9ec8dc4d --- /dev/null +++ b/Source/Client/AsyncTime/Quests/IAsyncQuestContextProvider.cs @@ -0,0 +1,10 @@ +using Multiplayer.Client.Quests; +using RimWorld; + +namespace Multiplayer.Client.AsyncTime.Quests +{ + internal interface IAsyncQuestContextProvider + { + IAsyncQuestContext TryGetAsyncQuestContext(Quest quest); + } +} diff --git a/Source/Client/AsyncTime/Quests/QuestAsyncPatches.cs b/Source/Client/AsyncTime/Quests/QuestAsyncPatches.cs new file mode 100644 index 00000000..3aa38c38 --- /dev/null +++ b/Source/Client/AsyncTime/Quests/QuestAsyncPatches.cs @@ -0,0 +1,120 @@ +using HarmonyLib; +using Multiplayer.Client.Quests; +using RimWorld; +using RimWorld.Planet; +using RimWorld.QuestGen; +using System.Collections.Generic; +using System.Reflection; +using Verse; + +namespace Multiplayer.Client.AsyncTime.Quests +{ + + [HarmonyPatch(typeof(SettlementAbandonUtility), nameof(SettlementAbandonUtility.Abandon))] + static class RemoveQuestsForMap + { + static void Prefix(MapParent settlement) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.asyncTime) return; + + IAsyncQuestContext asyncQuestContext = settlement.Map.AsyncTime(); + + if (asyncQuestContext != null) + { + Multiplayer.QuestManagerAsync.RemoveQuestsForContext(asyncQuestContext); + } + } + } + + [HarmonyPatch(typeof(QuestGen), nameof(QuestGen.Generate))] + static class CacheQuestAfterGeneration + { + static void Postfix(ref Quest __result) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.asyncTime) return; + + Multiplayer.QuestManagerAsync.AddQuest(__result); + } + } + + [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.QuestManagerTick))] + [HarmonyPriority(Priority.Last)] + static class DisableQuestManagerTickTest + { + static bool Prefix() + { + if (Multiplayer.Client == null) return true; + if (!Multiplayer.GameComp.asyncTime) return true; + + return false; + } + } + + [HarmonyPatch(typeof(Game), nameof(Game.FinalizeInit))] + [HarmonyPriority(Priority.Last)] + static class SetupAsyncTimeLookupForQuests + { + static void Postfix() + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.asyncTime) return; + + Multiplayer.QuestManagerAsync.SetQuestsAfterGameInit(Find.QuestManager.QuestsListForReading); + } + } + + [HarmonyPatch] + static class SetContextForQuest + { + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceAppeared)); + yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceAccepted)); + yield return AccessTools.PropertyGetter(typeof(Quest), nameof(Quest.TicksSinceCleanup)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.SetInitiallyAccepted)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.CleanupQuestParts)); + } + + static void Prefix(Quest __instance, ref IAsyncQuestContext __state, MethodBase __originalMethod) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.asyncTime) return; + + __state = Multiplayer.QuestManagerAsync.TryGetAsyncQuestContext(__instance); + __state?.PreContext(); + } + + static void Postfix(IAsyncQuestContext __state) => __state?.PostContext(); + } + + [HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] + static class SetContextForAccept + { + static void Prefix(Quest __instance, ref IAsyncQuestContext __state) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.asyncTime) return; + if (__instance.State != QuestState.NotYetAccepted) return; + if (__instance.parts == null) return; + + __state = Multiplayer.QuestManagerAsync.AddQuest(__instance); + __state?.PreContext(); + } + + static void Postfix(IAsyncQuestContext __state) => __state?.PostContext(); + } + + [HarmonyPatch(typeof(Quest), nameof(Quest.End))] + static class RemoveQuestFromCacheOnQuestEnd + { + // TODO: Quests that expire before being accepted and end up in Quest history aren’t removed from the cache. + // Add logic to detect these expired quests and ensure they’re purged. + static void Postfix(Quest __instance) + { + if (Multiplayer.Client == null) return; + Multiplayer.QuestManagerAsync.EndQuest(__instance); + } + } +} diff --git a/Source/Client/AsyncTime/Quests/QuestManagerAsync.cs b/Source/Client/AsyncTime/Quests/QuestManagerAsync.cs new file mode 100644 index 00000000..4303d4b2 --- /dev/null +++ b/Source/Client/AsyncTime/Quests/QuestManagerAsync.cs @@ -0,0 +1,134 @@ +using Multiplayer.Client.Quests; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld; +using System.Collections.Generic; +using System.Linq; +using Verse; + +namespace Multiplayer.Client.AsyncTime.Quests +{ + public class QuestManagerAsync + { + readonly TickableQuestCache _worldTickingQuests; + readonly Dictionary _asyncTickingQuests; + + readonly IAsyncQuestContextProvider _asyncQuestContextProvider; + + public QuestManagerAsync() + { + _asyncQuestContextProvider = new AsyncQuestContextProviderByMap(); + _asyncTickingQuests = new Dictionary(); + _worldTickingQuests = new TickableQuestCache(); + } + + public void TickQuestsAsync(IAsyncQuestContext context) + { + if(_asyncTickingQuests.TryGetValue(context, out var questTicker)) + { + questTicker.TickQuests(); + } + } + + public void TickWorldQuests() + { + _worldTickingQuests.TickQuests(); + } + + public void SetQuestsAfterGameInit(List quests) + { + foreach (var quest in quests) + { + AddQuest(quest); + } + } + + public IAsyncQuestContext AddQuest(Quest quest) + { + if (!Multiplayer.GameComp.asyncTime || quest == null) return null; + + var questContext = _asyncQuestContextProvider.TryGetAsyncQuestContext(quest); + + if (questContext != null) + { + RegisterInContext(questContext, quest); + MpLog.Debug($"Info: Found AsyncTimeMap: '{questContext.GetQuestContextInfo()}' for Quest: '{quest.name}'"); + } + else + { + _worldTickingQuests.AddQuest(quest); + MpLog.Debug($"Info: Could not find QuestContext for Quest: '{quest.name}'"); + } + + return questContext; + } + + public void EndQuest(Quest quest) + { + var allQuests = _asyncTickingQuests.Values.Append(_worldTickingQuests); + + foreach (var quests in allQuests) + { + if (quests.HasQuest(quest)) + { + quests.MarkQuestAsRemovable(quest); + } + } + } + + public void RemoveQuestsForContext(IAsyncQuestContext questContext) + { + if (_asyncTickingQuests.TryGetValue(questContext, out var tickableQuests)) + { + // TODO: Evaluate whether adding quests to worldTickingQuests is necessary. + // This method is triggered when the context—specifically a settlement with an AsyncTimeComponent—is abandoned. + // Since abandoning a settlement also removes its associated quests, + // it's unclear why these quests need to be added to worldTickingQuests at this point. + _worldTickingQuests.AddQuestRange(tickableQuests.Quests); + + _asyncTickingQuests.Remove(questContext); + } + } + + public IAsyncQuestContext TryGetAsyncQuestContext(Quest quest) + { + if (!Multiplayer.GameComp.asyncTime || quest == null) return null; + + return _asyncTickingQuests.FirstOrDefault(x => x.Value.HasQuest(quest)).Key; + } + + private void RegisterInContext(IAsyncQuestContext mapAsyncTimeComp, Quest quest) + { + RemoveQuest(quest); + + if (_asyncTickingQuests.TryGetValue(mapAsyncTimeComp, out var mapTickableQuests)) + { + mapTickableQuests.AddQuest(quest); + } + else + { + _asyncTickingQuests[mapAsyncTimeComp] = new TickableQuestCache(); + _asyncTickingQuests[mapAsyncTimeComp].AddQuest(quest); + } + } + private void RemoveQuest(Quest quest) + { + int questRemovedCount = 0; + + if (_worldTickingQuests.RemoveQuest(quest)) + questRemovedCount++; + + foreach (TickableQuestCache quests in _asyncTickingQuests.Values) + { + if (quests.RemoveQuest(quest)) + questRemovedCount++; + } + + if (questRemovedCount > 1) + { + MpLog.Debug($"Warning: Quest '{quest?.name ?? "null"}' was removed from multiple caches ({questRemovedCount} times). This may indicate a logic error or duplication."); + } + } + } +} + diff --git a/Source/Client/AsyncTime/Quests/TickableQuestCache.cs b/Source/Client/AsyncTime/Quests/TickableQuestCache.cs new file mode 100644 index 00000000..2a03748d --- /dev/null +++ b/Source/Client/AsyncTime/Quests/TickableQuestCache.cs @@ -0,0 +1,65 @@ +using RimWorld; +using System.Collections.Generic; + +namespace Multiplayer.Client.AsyncTime.Quests +{ + internal class TickableQuestCache + { + public IReadOnlyCollection Quests => _quests; + + HashSet _quests; + List _questsToRemove; + + public TickableQuestCache() + { + _quests = new(); + _questsToRemove = new(); + } + + public void AddQuest(Quest quest) + { + _quests.Add(quest); + } + + public void AddQuestRange(IEnumerable quests) + { + _quests.UnionWith(quests); + } + + public void MarkQuestAsRemovable(Quest quest) + { + _questsToRemove.Add(quest); + } + + public bool RemoveQuest(Quest quest) + { + _questsToRemove.Remove(quest); + return _quests.Remove(quest); + } + + public bool HasQuest(Quest quest) + { + return _quests.Contains(quest); + } + + public void TickQuests() + { + foreach (Quest quest in _quests) + { + quest.QuestTick(); + } + + ProcessQuestsToRemove(); + } + + private void ProcessQuestsToRemove() + { + if (_questsToRemove.Count == 0) return; + + foreach (Quest quest in _questsToRemove) + { + _quests.Remove(quest); + } + } + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 628aa981..8c4c4511 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -15,6 +15,7 @@ using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Util; +using Multiplayer.Client.AsyncTime.Quests; namespace Multiplayer.Client { @@ -44,6 +45,8 @@ public static class Multiplayer public static MultiplayerWorldComp WorldComp => game.worldComp; public static AsyncWorldTimeComp AsyncWorldTime => game.asyncWorldTimeComp; + public static QuestManagerAsync QuestManagerAsync => game.questManagerAsync; + public static bool ShowDevInfo => Prefs.DevMode && settings.showDevInfo; public static bool GhostMode => session is { ghostModeCheckbox: true }; diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index 0eec307d..67f0cc1f 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -11,6 +11,7 @@ using Multiplayer.Client.Factions; using UnityEngine; using Verse; +using Multiplayer.Client.AsyncTime.Quests; namespace Multiplayer.Client { @@ -24,6 +25,7 @@ public class MultiplayerGame public List mapComps = new(); public List asyncTimeComps = new(); public SharedCrossRefs sharedCrossRefs = new(); + public QuestManagerAsync questManagerAsync = new(); private Faction myFaction; public Faction myFactionLoading;