From e96b3c9f4bd5bb07d479c005348f8bc8a31c0713 Mon Sep 17 00:00:00 2001 From: SokyranTheDragon Date: Sun, 14 Jul 2024 20:37:52 +0200 Subject: [PATCH 1/3] Psychic ritual syncing I've decided to look into this over the past 2-3 days and have mostly finished it, however there's still a few things that are needed (so it's currently a draft). Things still needing work: - Modify ritual prepatches to also work with psychic rituals, so assigning roles will be synced - Add localization for `MpPsychicRitualSession` Info on psychic ritual patches I've included: - Heavily based on ritual patches (started by copy-pasting the code related to them) - I've not used "PsychicRitualData" like how it's done with "RitualData" for normal rituals, as I've decided there wasn't as much data to handle - I've synced the start call by overriding the "Start" method in ritual proxy - This can also be applied to normal rituals to remove a harmony patch - In case of normal rituals, we could also include a confirmation dialog to match vanilla behaviour - Syncing of creating session was done by patching `PsychicRitualGizmo.InitializePsychicRitual` - As opposed to normal rituals that can be started in multiple places, this is the only location those can be created from - I believe everything else should have comments, match behaviour of other session, or be self-explanatory - If explanation for anything else is needed, let me know, and I'll include it in code comments or on GitHub A slight side note about dialogs with sessions (and styling station dialog) - perhaps we should make a single harmony patch to `Widgets.ButtonTextWorker` with a generalized way to use it? As it stands right now, we basically make two patches per session (prefix and postfix). Having a more generalized way to handle it may be useful in the future. --- .../Persistent/PsychicRitualBeginProxy.cs | 57 +++++++++ .../Client/Persistent/PsychicRitualPatches.cs | 45 +++++++ .../Client/Persistent/PsychicRitualSession.cs | 120 ++++++++++++++++++ Source/Client/Syncing/Dict/SyncDictDlc.cs | 65 +++++++++- Source/Client/Syncing/Game/SyncDelegates.cs | 9 ++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 Source/Client/Persistent/PsychicRitualBeginProxy.cs create mode 100644 Source/Client/Persistent/PsychicRitualPatches.cs create mode 100644 Source/Client/Persistent/PsychicRitualSession.cs diff --git a/Source/Client/Persistent/PsychicRitualBeginProxy.cs b/Source/Client/Persistent/PsychicRitualBeginProxy.cs new file mode 100644 index 00000000..ae915b9e --- /dev/null +++ b/Source/Client/Persistent/PsychicRitualBeginProxy.cs @@ -0,0 +1,57 @@ +using RimWorld; +using UnityEngine; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Persistent; + +public class PsychicRitualBeginProxy : Dialog_BeginPsychicRitual, ISwitchToMap +{ + public static PsychicRitualBeginProxy drawing; + + public PsychicRitualSession Session => map.MpComp().sessionManager.GetFirstOfType(); + + public PsychicRitualBeginProxy( + PsychicRitualDef def, + PsychicRitualCandidatePool candidatePool, + PsychicRitualRoleAssignments assignments, + Map map) : + base(def, candidatePool, assignments, map) + { + // Recache the pending issues for the ritual. + // Each time the ritual is started, this method is called. However, + // in MP we can have multiple rituals active at a time, so ensure + // that we recache if the ritual is valid on a specific map. + // If there's ever issues with this, we may need to call this + // in DoWindowContents, however it shouldn't be needed. + def.InitializeCast(map); + } + + public override void DoWindowContents(Rect inRect) + { + drawing = this; + + try + { + var session = Session; + + if (session == null) + { + soundClose = SoundDefOf.Click; + Close(); + } + + base.DoWindowContents(inRect); + } + finally + { + drawing = null; + } + } + + public override void Start() + { + if (CanBegin) + Session?.Start(); + } +} diff --git a/Source/Client/Persistent/PsychicRitualPatches.cs b/Source/Client/Persistent/PsychicRitualPatches.cs new file mode 100644 index 00000000..dcc4596d --- /dev/null +++ b/Source/Client/Persistent/PsychicRitualPatches.cs @@ -0,0 +1,45 @@ +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Persistent; + +[HarmonyPatch(typeof(Widgets), nameof(Widgets.ButtonTextWorker))] +static class MakeCancelPsychicRitualButtonRed +{ + static void Prefix(string label, ref bool __state) + { + if (PsychicRitualBeginProxy.drawing == null) return; + if (label != "CancelButton".Translate()) return; + + GUI.color = new Color(1f, 0.3f, 0.35f); + __state = true; + } + + static void Postfix(bool __state, ref Widgets.DraggableResult __result) + { + if (!__state) return; + + GUI.color = Color.white; + if (__result.AnyPressed()) + { + PsychicRitualBeginProxy.drawing.Session?.Remove(); + __result = Widgets.DraggableResult.Idle; + } + } +} + +[HarmonyPatch(typeof(PsychicRitualGizmo), nameof(PsychicRitualGizmo.InitializePsychicRitual))] +static class CancelDialogBeginPsychicRitual +{ + static bool Prefix(PsychicRitualDef_InvocationCircle psychicRitualDef, Thing target) + { + if (Multiplayer.Client == null) + return true; + + PsychicRitualSession.OpenOrCreateSession(psychicRitualDef, target); + return false; + } +} diff --git a/Source/Client/Persistent/PsychicRitualSession.cs b/Source/Client/Persistent/PsychicRitualSession.cs new file mode 100644 index 00000000..edb5257c --- /dev/null +++ b/Source/Client/Persistent/PsychicRitualSession.cs @@ -0,0 +1,120 @@ +using Multiplayer.API; +using RimWorld; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Persistent; + +public class PsychicRitualSession : SemiPersistentSession, ISessionWithCreationRestrictions +{ + public Map map; + public PsychicRitualDef ritual; + public PsychicRitualCandidatePool candidatePool; + public MpPsychicRitualAssignments assignments; + + public override Map Map => map; + + public PsychicRitualSession(Map map) : base(map) + { + this.map = map; + } + + public PsychicRitualSession(Map map, PsychicRitualDef ritual, PsychicRitualCandidatePool candidatePool, MpPsychicRitualAssignments assignments) : this(map) + { + this.ritual = ritual; + this.assignments = assignments; + this.candidatePool = candidatePool; + this.assignments.session = this; + } + + public static void OpenOrCreateSession(PsychicRitualDef_InvocationCircle ritual, Thing target) + { + // We need Find.CurrentMap to match the map we're creating the session in + var map = Find.CurrentMap; + if (map != target.Map) + { + Log.Error($"Error opening/creating {nameof(PsychicRitualSession)} - current map ({Find.CurrentMap}) does not match ritual spot map ({target.Map})."); + return; + } + + var session = map.MpComp().sessionManager.GetFirstOfType(); + if (session == null) + CreateSession(ritual, target); + else + session.OpenWindow(); + } + + // Need CurrentMap for PsychicRitualDef.FindCandidatePool call + [SyncMethod(SyncContext.CurrentMap)] + public static void CreateSession(PsychicRitualDef_InvocationCircle ritual, Thing target) + { + var map = Find.CurrentMap; + + // Get role assignments and candidate pool + var candidatePool = ritual.FindCandidatePool(); + var assignments = MpUtil.ShallowCopy(ritual.BuildRoleAssignments(target), new MpPsychicRitualAssignments()); + + var manager = map.MpComp().sessionManager; + var session = manager.GetOrAddSession(new PsychicRitualSession(map, ritual, candidatePool, assignments)); + + if (TickPatch.currentExecutingCmdIssuedBySelf) + session.OpenWindow(); + } + + [SyncMethod] + public void Remove() + { + map.MpComp().sessionManager.RemoveSession(this); + } + + [SyncMethod] + public void Start() + { + Remove(); + ritual.MakeNewLord(assignments); + Find.PsychicRitualManager.RegisterCooldown(ritual); + } + + public void OpenWindow(bool sound = true) + { + var dialog = new PsychicRitualBeginProxy( + ritual, + candidatePool, + assignments, + map); + + if (!sound) + dialog.soundAppear = null; + + Find.WindowStack.Add(dialog); + } + + public override void Sync(SyncWorker sync) + { + sync.Bind(ref ritual); + sync.Bind(ref candidatePool); + + SyncType assignmentsType = typeof(MpPsychicRitualAssignments); + assignmentsType.expose = true; + sync.Bind(ref assignments, assignmentsType); + assignments.session = this; + } + + public override bool IsCurrentlyPausing(Map map) => map == this.map; + + public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) + { + return new FloatMenuOption("MpPsychicRitualSession".Translate(), () => + { + SwitchToMapOrWorld(entry.map); + OpenWindow(); + }); + } + + public bool CanExistWith(Session other) => other is not PsychicRitualSession; +} + +public class MpPsychicRitualAssignments : PsychicRitualRoleAssignments +{ + public PsychicRitualSession session; +} diff --git a/Source/Client/Syncing/Dict/SyncDictDlc.cs b/Source/Client/Syncing/Dict/SyncDictDlc.cs index ce91639e..50ad7b0c 100644 --- a/Source/Client/Syncing/Dict/SyncDictDlc.cs +++ b/Source/Client/Syncing/Dict/SyncDictDlc.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Multiplayer.API; using Multiplayer.Client.Persistent; @@ -313,7 +314,69 @@ public static class SyncDictDlc return (ActivityGizmo)comp.gizmo; } - } + }, + { + (ByteWriter data, PsychicRitualRoleAssignments assignments) => + { + // In Multiplayer, PsychicRitualRoleAssignments should only be of the wrapper type MpRitualAssignments + var mpAssignments = (MpPsychicRitualAssignments)assignments; + data.MpContext().map = mpAssignments.session.map; + data.WriteInt32(mpAssignments.session.SessionId); + }, + (ByteReader data) => + { + var id = data.ReadInt32(); + var ritual = data.MpContext().map.MpComp().sessionManager.GetFirstWithId(id); + return ritual?.assignments; + } + }, + { + (ByteWriter data, PsychicRitualCandidatePool candidatePool) => + { + WriteSync(data, candidatePool.AllCandidatePawns); + WriteSync(data, candidatePool.NonAssignablePawns); + }, + (ByteReader data) => + { + var allCandidates = ReadSync>(data); + var nonAssignable = ReadSync>(data); + + return new PsychicRitualCandidatePool(allCandidates, nonAssignable); + } + }, + { + (ByteWriter data, PsychicRitual ritual) => + { + var lordToil = ritual?.lord?.CurLordToil as LordToil_PsychicRitual; + WriteSync(data, lordToil); + + // We could skip syncing the ID, I've decided to include it for additional error checking + if (lordToil != null) + data.WriteInt32(ritual.loadID); + }, + (ByteReader data) => + { + var lordToil = ReadSync(data); + if (lordToil == null) + return null; + + var ritualId = data.ReadInt32(); + var ritual = lordToil.RitualData.psychicRitual; + + if (ritual == null) + { + Log.Error("Psychic ritual was null after syncing"); + return null; + } + if (ritual.loadID != ritualId) + { + Log.Error($"Synced psychic ritual ID did not match after syncing, expected: {ritualId}, current: {ritual.loadID}"); + return null; + } + + return ritual; + }, true // implicit + }, #endregion }; diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index caf07fa3..d21b004b 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -9,6 +9,7 @@ using Multiplayer.Client.Patches; using MultiplayerLoader; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client { @@ -271,6 +272,11 @@ private static void InitRituals() SyncDelegate.Lambda(typeof(Dialog_BeginRitual), nameof(Dialog_BeginRitual.DrawRoleSelection), 3); // Select role, set confirm text SyncDelegate.Lambda(typeof(Dialog_BeginRitual), nameof(Dialog_BeginRitual.DrawRoleSelection), 4); // Select role, no confirm text + SyncMethod.Register(typeof(PsychicRitual), nameof(PsychicRitual.CancelPsychicRitual)); + SyncMethod.Register(typeof(PsychicRitual), nameof(PsychicRitual.LeavePsychicRitual)); // Make pawn leave ritual + + SyncMethod.Register(typeof(GameComponent_PsychicRitualManager), nameof(GameComponent_PsychicRitualManager.ClearAllCooldowns)).SetDebugOnly(); // Dev reset all psychic ritual cooldowns + /* PawnRoleSelectionWidgetBase @@ -287,6 +293,9 @@ The UI's main interaction area is split into three types of groups of pawns. SyncMethod.Register(typeof(RitualRoleAssignments), nameof(RitualRoleAssignments.TryAssignSpectate)); SyncMethod.Register(typeof(RitualRoleAssignments), nameof(RitualRoleAssignments.RemoveParticipant)); + SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.TryAssignSpectate)); + SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.RemoveParticipant)); + SyncRituals.ApplyPrepatches(null); } From 369328fcf9c42edd28d61c8fb16ee590d865c10f Mon Sep 17 00:00:00 2001 From: SokyranTheDragon Date: Thu, 1 Aug 2024 22:33:36 +0200 Subject: [PATCH 2/3] Include patches for ritual syncing, fix desync First, a psychic ritual dialog desync was fixed by seeding the RNG when it may end up being used. Second (and more importantly), the psychic rituals were synced further. I hope that everything should work now, unless I've missed something. I have to admit - syncing was done it a rather crude and simple way. I've changed the code to apply the same prepatches for both rituals and psychic rituals (which means 2 patches are applied to those methods now). This approach could be improved by changing `PrepatcherPrefix` to accept all subtypes (rather than just having an exact type match). This will reduce the amount of prepatches needed, but (without further changes) won't fully eliminate them - 2 of the patches rely on transforming the arguments, and I could not think of a clear and simple way to handle them. In any case, I've extracted the argument transformer writer and reader used there into their own methods, and I've made them generic methods that will handle both normal and psychic rituals. A final addition was adding a sync worker for `PawnPsychicRitualRoleSelectionWidget`, as it's used by the synced methods. --- .../Persistent/PsychicRitualBeginProxy.cs | 34 ++++++++++--- Source/Client/Syncing/Dict/SyncDictDlc.cs | 18 +++++++ Source/MultiplayerLoader/SyncRituals.cs | 51 ++++++++++++++++--- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/Source/Client/Persistent/PsychicRitualBeginProxy.cs b/Source/Client/Persistent/PsychicRitualBeginProxy.cs index ae915b9e..43f12da4 100644 --- a/Source/Client/Persistent/PsychicRitualBeginProxy.cs +++ b/Source/Client/Persistent/PsychicRitualBeginProxy.cs @@ -18,13 +18,33 @@ public PsychicRitualBeginProxy( Map map) : base(def, candidatePool, assignments, map) { - // Recache the pending issues for the ritual. - // Each time the ritual is started, this method is called. However, - // in MP we can have multiple rituals active at a time, so ensure - // that we recache if the ritual is valid on a specific map. - // If there's ever issues with this, we may need to call this - // in DoWindowContents, however it shouldn't be needed. - def.InitializeCast(map); + var session = Session; + + if (Session == null) + { + Log.Error("Trying to open a psychic ritual dialog proxy without session active"); + return; + } + + try + { + // Ensure that InitializeCast call is seeded, use session and map IDs to get a somewhat random value. + // We could also include current map tick as well, if needed. + Rand.PushState(Gen.HashCombineInt(session.SessionId, map.uniqueID)); + + // Recache the pending issues for the ritual. + // Each time the ritual is started, this method is called. However, + // in MP we can have multiple rituals active at a time, so ensure + // that we recache if the ritual is valid on a specific map. + // If there's ever issues with this, we may need to call this + // in DoWindowContents, however it shouldn't be needed. + def.InitializeCast(map); + } + finally + { + // Pop RNG state in finally to ensure no issues when an exception occurs. + Rand.PopState(); + } } public override void DoWindowContents(Rect inRect) diff --git a/Source/Client/Syncing/Dict/SyncDictDlc.cs b/Source/Client/Syncing/Dict/SyncDictDlc.cs index 50ad7b0c..85397563 100644 --- a/Source/Client/Syncing/Dict/SyncDictDlc.cs +++ b/Source/Client/Syncing/Dict/SyncDictDlc.cs @@ -377,6 +377,24 @@ public static class SyncDictDlc return ritual; }, true // implicit }, + { + // Currently only used for Dialog_BeginPsychicRitual delegate syncing + (ByteWriter data, PawnPsychicRitualRoleSelectionWidget dialog) => + { + // psychicRitualAssignments and assignments fields store the same object. + // If we used assignments field we would have to cast it before syncing. + WriteSync(data, dialog.psychicRitualAssignments); + WriteSync(data, dialog.ritualDef); + }, + (ByteReader data) => + { + var assignments = (MpPsychicRitualAssignments)ReadSync(data); + if (assignments == null) return null; + + var ritual = ReadSync(data); // todo handle ritual becoming null? + return new PawnPsychicRitualRoleSelectionWidget(ritual, assignments.session.candidatePool, assignments); + } + }, #endregion }; diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 759b4cb3..8ad4a70b 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -55,25 +55,33 @@ void Register(Type baseType, string method, Type derivedType, Action roles, object target, object[] _) => - { - var roleSelectionWidget = (PawnRitualRoleSelectionWidget)target; - return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); - }, - data => data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key) + var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader + ); + var psychicRitualRolesSerializer = Serializer.New, (PawnPsychicRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader ); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), typeof(PawnRitualRoleSelectionWidget), m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), typeof(PawnRitualRoleSelectionWidget)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnPsychicRitualRoleSelectionWidget)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnRitualRoleSelectionWidget), m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); Register( typeof(PawnRoleSelectionWidgetBase<>), @@ -83,6 +91,14 @@ void Register(Type baseType, string method, Type derivedType, Action), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 7).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Roles right click delegate (try assign spectate) Register( typeof(PawnRoleSelectionWidgetBase<>), @@ -92,5 +108,26 @@ void Register(Type baseType, string method, Type derivedType, Action), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 2).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Not participating left click delegate (try assign any role or spectate) + } + + private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) + where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole + { + var roleSelectionWidget = (WidgetType)target; + return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); + } + + private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) + where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole + { + return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); } } From 0a15ae3517db5c23d097fa0f09d0d582b1d6c19a Mon Sep 17 00:00:00 2001 From: SokyranTheDragon Date: Thu, 1 Aug 2024 22:43:06 +0200 Subject: [PATCH 3/3] Slight readability change to generic constraints --- Source/MultiplayerLoader/SyncRituals.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 8ad4a70b..24a1c4af 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -119,14 +119,16 @@ void Register(Type baseType, string method, Type derivedType, Action(IEnumerable roles, object target, object[] _) - where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole { var roleSelectionWidget = (WidgetType)target; return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); } private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) - where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole { return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); }