diff --git a/.gitignore b/.gitignore index b2cb6d1..945c553 100644 --- a/.gitignore +++ b/.gitignore @@ -331,6 +331,7 @@ ASALocalRun/ /Assemblies/ /Referenced/ +scratch/ Multiplayer-Compatibility.zip /Multiplayer-Compatibility \ No newline at end of file diff --git a/README.md b/README.md index c153d44..9df1b6a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Requires latest Zetrith's Multiplayer: [Download](https://github.com/rwmt/Multip Support added to these mods | _ ---|--- +Adaptive Work Priorities | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3792993740) Allow Tool | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=761421485) [![url][repo]](https://github.com/UnlimitedHugs/RimworldAllowTool) Almost There! | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2372543327) [![url][repo]](https://github.com/rheirman/AlmostThere) Alpha Animals | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=1541721856) [![url][repo]](https://github.com/juanosarg/AlphaAnimals) @@ -32,6 +33,7 @@ Biotech Expansion - Mythic | [![url][steam]](https://steamcommunity.com/sharedfi Biotech Mech Stuff Elongated - Mechanoid Upgrades V2 | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2926928394) Blueprints | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=708455313) [![url][repo]](https://github.com/fluffy-mods/Blueprints) Cash Register | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2506046833) [![url][repo]](https://github.com/OrionFive/CashRegister) +Character Creator | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3762126187) Choice Of Psycasts | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2293460251) Cleaning Area | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=870089952) Clean Pathfinding 2 | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2603765747) [![url][repo]](https://github.com/Owlchemist/clean-pathfinding) @@ -54,6 +56,7 @@ Eccentric Tech - Fusion Power | [![url][steam]](https://steamcommunity.com/share Enhanced Vat Learning | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2908453851) [![url][repo]](https://github.com/SmArtKar/EnhancedVatLearning) Extended Bioengineering for VFE Insectoids | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2706534548) Fertile Fields | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2012735237) +Flourish | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3793154978) Follow Me | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=715759739) [![url][repo]](https://github.com/fluffy-mods/FollowMe) Gastronomy | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2279786905) [![url][repo]](https://github.com/OrionFive/Gastronomy) Gene Ripper | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2885485814) @@ -62,14 +65,22 @@ Giddy-Up 2 | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/ Grazing Lands | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=1770268130) [![url][repo]](https://github.com/catgirlfighter/RimWorld_GrazingLands) Healer Mech Serum Choice | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2714095848) [![url][repo]](https://github.com/XT-0xFF/HealerMechSerumChoice) Immortals | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=1984905966) +Isekai RPG Leveling | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3657580708) +Isekai RPG - Guild Faction Add-on | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3718568958) +Isekai RPG - Creatures Add-on | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3757560461) LightsOut | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2584269293) [![url][repo]](https://github.com/NathanIkola/LightsOut) Medical Tab | [![url][steam]](https://steamcommunity.com/workshop/filedetails/?id=715565817) [![url][repo]](https://github.com/fluffy-mods/MedicalTab) Medicines+ | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2558272601) [![url][repo]](https://github.com/Atla55/Medicines-Plus) Minify Everything | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=872762753) [![url][repo]](https://github.com/erdelf/MinifyEverything) Misc. Robots | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=724602224) Misc. Training | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=717575199) +Modern Faction Menu | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3742926690) +Modern History Menu | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3742925193) +Modern Quest Menu | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3742906203) +Modern Social Tab | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3740700588) More Archotech Garbage | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2391102796) More Faction Interaction | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2379076640) [![url][repo]](https://github.com/emipa606/MoreFactionInteraction) +Nice Bill Tab & Nice Bill Tab Expansion | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3520130671) [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3721023311) One bed to sleep with all | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2572109781) [![url][repo]](https://github.com/densevoid/one-bed-to-sleep-with-all) Path Avoid | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=1180719857) [![url][repo]](https://github.com/KiameV/rimworld-pathavoid) Performance Optimizer | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2664723367) [![url][repo]](https://github.com/Taranchuk/PerformanceOptimizer) @@ -82,6 +93,12 @@ PowerSwitch | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails Prison Commons | [![url][steam]](https://steamcommunity.com/workshop/filedetails/?id=2630896782) Processor Framework | [![url][steam]](https://steamcommunity.com/workshop/filedetails/?id=2633514537) [![url][repo]](https://github.com/Syrchalis/ProcessorFramework) Quality Builder | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=754637870) +Radius UI - Colonist Bar | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3788278207) +Radius UI - Faction Menu | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3786945713) +Radius UI - Health Tab | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3786129210) +Radius UI - Inspector | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3786131805) +Radius UI - Mission Control | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3788256848) +Radius UI - Quest Menu | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3786840944) ReGrowth: Core | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2260097569) [![url][repo]](https://github.com/Helixien/ReGrowth-Core) Reinforced Mechanoid 2 | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2910050186) [![url][repo]](https://github.com/Helixien/ReinforcedMechanoids-2) ResearchPowl | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2877856030) @@ -112,6 +129,7 @@ Tacticowl | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/? TD Enhancement Pack - Continued | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3525414162) [![url][repo]](https://github.com/MemeGoddess/RimWorld-EnhancementPack) Time of day switches | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=776114412) Toggleable Shields | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2690413766) [![url][repo]](https://github.com/Owlchemist/toggleable-shields) +True RPG Inventory | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3744201621) Utility Columns | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2013476665) [![url][repo]](https://github.com/RealTelefonmast/UtilityColumns) Vanilla Achievements Expanded | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2288125657) [![url][repo]](https://github.com/Vanilla-Expanded/VanillaAchievementsExpanded) Vanilla Animals Expanded - Caves | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2576512001) [![url][repo]](https://github.com/Vanilla-Expanded/VanillaAnimalsExpanded-Caves) diff --git a/Source/Mods/AdaptiveWorkPriorities.cs b/Source/Mods/AdaptiveWorkPriorities.cs new file mode 100644 index 0000000..4a229a8 --- /dev/null +++ b/Source/Mods/AdaptiveWorkPriorities.cs @@ -0,0 +1,1245 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Adaptive Work Priorities by Astryl + /// + [MpCompatFor("astryl.adaptiveworkpriorities")] + internal class AdaptiveWorkPriorities + { + // Types + private static Type learningStoreType; + private static Type proposalType; + private static Type pawnProfileType; + private static Type priorityWriterType; + private static Type emergencyManagerType; + private static Type demandProbeType; + private static Type activeCoverType; + private static Type emergencyViewType; + private static Type mainWindowType; + private static Type pawnCardType; + private static Type awSettingsType; + private static Type awModType; + private static Type workTypeTuningDefType; + private static Type emergencyStateDefType; + private static Type emergencyProfileEntryType; + private static Type priorityRangesType; + private static Type toastsType; + + // Reflection Accessors - LearningStore + private static PropertyInfo learningStoreInstanceProp; + private static MethodInfo learningStoreApplyProposalMethod; + private static MethodInfo learningStoreSnoozeMethod; + private static MethodInfo learningStoreNeverMethod; + private static MethodInfo learningStoreResetLearningMethod; + private static MethodInfo learningStoreUndoLastMethod; + private static MethodInfo learningStoreMarkDirtyMethod; + private static MethodInfo learningStoreMarkAllDirtyMethod; + private static MethodInfo learningStoreTracksMethod; + private static MethodInfo learningStoreProfileForMethod; + private static MethodInfo learningStoreProposalsForMethod; + private static MethodInfo learningStoreFindPawnMethod; + private static MethodInfo learningStoreChainForMethod; + private static MethodInfo learningStoreTryEmitDigestMethod; + private static AccessTools.FieldRef lastWriterPassAtField; + private static AccessTools.FieldRef writerBucketField; + private static AccessTools.FieldRef schedulerRosterField; + private static AccessTools.FieldRef storeCoversField; + private static AccessTools.FieldRef> storeDirtyField; + + // Reflection Accessors - Proposal + private static AccessTools.FieldRef proposalPawnId; + private static AccessTools.FieldRef proposalWt; + private static AccessTools.FieldRef proposalFrom; + private static AccessTools.FieldRef proposalTo; + private static AccessTools.FieldRef proposalConf; + private static AccessTools.FieldRef proposalAff; + private static AccessTools.FieldRef proposalEvCount; + private static AccessTools.FieldRef proposalSkill; + private static AccessTools.FieldRef proposalPassion; + private static AccessTools.FieldRef proposalCoverage; + private static AccessTools.FieldRef proposalDemote; + private static AccessTools.FieldRef proposalColdDays; + private static PropertyInfo proposalCellKeyProp; + + // Reflection Accessors - PawnProfile + private static AccessTools.FieldRef pawnProfileIdField; + private static AccessTools.FieldRef pawnProfileModeField; + private static AccessTools.FieldRef> pawnProfileLockedField; + + // Reflection Accessors - PriorityWriter + private static MethodInfo priorityWriterApplyMethod; + private static MethodInfo priorityWriterApplyEmergencyMethod; + private static MethodInfo priorityWriterEnsureNumericModeMethod; + + // Reflection Accessors - EmergencyManager + private static MethodInfo emergencyManagerTickMethod; + private static MethodInfo emergencyManagerReleaseAllMethod; + private static MethodInfo emergencyManagerCoveredCountMethod; + private static MethodInfo emergencyManagerPickFromChainMethod; + private static MethodInfo emergencyManagerReconcileMethod; + private static MethodInfo emergencyManagerRefreshStatesMethod; + private static AccessTools.FieldRef emergencyActiveStatesField; + + // Reflection Accessors - ActiveCover + private static AccessTools.FieldRef activeCoverPawnId; + private static AccessTools.FieldRef activeCoverWt; + private static AccessTools.FieldRef activeCoverPrev; + private static AccessTools.FieldRef activeCoverApplied; + private static AccessTools.FieldRef activeCoverSource; + private static AccessTools.FieldRef activeCoverStateDef; + + // Reflection Accessors - Settings & Misc + private static FieldInfo awModSettingsField; + private static AccessTools.FieldRef settingsMasterField; + private static AccessTools.FieldRef settingsModeField; + private static AccessTools.FieldRef settingsEmergencyCoverField; + private static AccessTools.FieldRef settingsEmergencyTriageField; + private static AccessTools.FieldRef settingsWriteIntervalTicksField; + private static AccessTools.FieldRef settingsWritesPerPassField; + private static AccessTools.FieldRef settingsSemiConfidenceField; + private static AccessTools.FieldRef settingsToastsSuggestField; + private static AccessTools.FieldRef settingsToastsLearningField; + private static MethodInfo writeSettingsMethod; + private static MethodInfo dropCellToastMethod; + private static MethodInfo emergencyCoverTuningMethod; + private static MethodInfo demandKindTuningMethod; + private static PropertyInfo priorityRangesMaxProp; + private static AccessTools.FieldRef emergencyViewSelField; + private static AccessTools.FieldRef stateDefProfileField; + private static AccessTools.FieldRef stateDefOrderField; + private static AccessTools.FieldRef profileEntryWorkTypeField; + private static AccessTools.FieldRef profileEntryPriorityField; + + // Runtime Multi-Map State + private static Map currentEvaluatingMap; + private static readonly Dictionary<(int mapId, string kind), bool> multiMapDemand = new(); + private static int multiMapDemandTick = -1; + private static readonly Dictionary<(int mapId, int mechs, ushort wtIndex), int> multiMapCoverage = new(); + private static int lastCoverageTick = -1; + + public AdaptiveWorkPriorities(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + // Ensure textures in AWTex are initialized on the main thread if needed + var awTexType = AccessTools.TypeByName("AdaptiveWork.Core.AWTex"); + if (awTexType != null) + { + var workAtlasField = AccessTools.Field(awTexType, "WorkAtlas"); + if (workAtlasField != null && workAtlasField.GetValue(null) == null) + { + awTexType.TypeInitializer?.Invoke(null, null); + } + } + + // Resolve Mod Types + learningStoreType = AccessTools.TypeByName("AdaptiveWork.Data.LearningStore"); + proposalType = AccessTools.TypeByName("AdaptiveWork.Model.Proposal"); + pawnProfileType = AccessTools.TypeByName("AdaptiveWork.Model.PawnProfile"); + priorityWriterType = AccessTools.TypeByName("AdaptiveWork.Engine.PriorityWriter"); + emergencyManagerType = AccessTools.TypeByName("AdaptiveWork.Engine.EmergencyManager"); + demandProbeType = AccessTools.TypeByName("AdaptiveWork.Engine.DemandProbe"); + activeCoverType = AccessTools.TypeByName("AdaptiveWork.Engine.ActiveCover"); + emergencyViewType = AccessTools.TypeByName("AdaptiveWork.UI.EmergencyView"); + mainWindowType = AccessTools.TypeByName("AdaptiveWork.UI.MainWindow"); + pawnCardType = AccessTools.TypeByName("AdaptiveWork.UI.PawnCard"); + awSettingsType = AccessTools.TypeByName("AdaptiveWork.AWSettings"); + awModType = AccessTools.TypeByName("AdaptiveWork.AWMod"); + workTypeTuningDefType = AccessTools.TypeByName("AdaptiveWork.WorkTypeTuningDef"); + emergencyStateDefType = AccessTools.TypeByName("AdaptiveWork.EmergencyStateDef"); + emergencyProfileEntryType = AccessTools.TypeByName("AdaptiveWork.EmergencyProfileEntry"); + priorityRangesType = AccessTools.TypeByName("AdaptiveWork.Engine.PriorityRanges"); + toastsType = AccessTools.TypeByName("AdaptiveWork.UI.Toasts"); + + if (learningStoreType == null || proposalType == null) + { + Log.Error("[MpCompat] AdaptiveWorkPriorities: Failed to resolve core types."); + return; + } + + // LearningStore Accessors + learningStoreInstanceProp = AccessTools.Property(learningStoreType, "Instance"); + learningStoreApplyProposalMethod = AccessTools.Method(learningStoreType, "ApplyProposal", new[] { typeof(Pawn), proposalType, typeof(bool) }); + learningStoreSnoozeMethod = AccessTools.Method(learningStoreType, "Snooze", new[] { typeof(Pawn), proposalType }); + learningStoreNeverMethod = AccessTools.Method(learningStoreType, "Never", new[] { typeof(Pawn), proposalType }); + learningStoreResetLearningMethod = AccessTools.Method(learningStoreType, "ResetLearning", new[] { typeof(Pawn) }); + learningStoreUndoLastMethod = AccessTools.Method(learningStoreType, "UndoLast"); + learningStoreMarkDirtyMethod = AccessTools.Method(learningStoreType, "MarkDirty", new[] { typeof(Pawn) }); + learningStoreMarkAllDirtyMethod = AccessTools.Method(learningStoreType, "MarkAllDirty"); + learningStoreTracksMethod = AccessTools.Method(learningStoreType, "Tracks", new[] { typeof(Pawn) }); + learningStoreProfileForMethod = AccessTools.Method(learningStoreType, "ProfileFor", new[] { typeof(Pawn) }); + learningStoreProposalsForMethod = AccessTools.Method(learningStoreType, "ProposalsFor", new[] { typeof(Pawn) }); + learningStoreFindPawnMethod = AccessTools.Method(learningStoreType, "FindPawn", new[] { typeof(int) }); + learningStoreChainForMethod = AccessTools.Method(learningStoreType, "ChainFor", new[] { typeof(WorkTypeDef) }); + learningStoreTryEmitDigestMethod = AccessTools.Method(learningStoreType, "TryEmitDigest", new[] { typeof(int) }); + lastWriterPassAtField = AccessTools.FieldRefAccess(learningStoreType, "lastWriterPassAt"); + writerBucketField = AccessTools.FieldRefAccess(learningStoreType, "writerBucket"); + schedulerRosterField = AccessTools.FieldRefAccess(learningStoreType, "schedulerRoster"); + storeCoversField = AccessTools.FieldRefAccess(learningStoreType, "Covers"); + storeDirtyField = AccessTools.FieldRefAccess>(learningStoreType, "dirty"); + + // Proposal Accessors + proposalPawnId = AccessTools.FieldRefAccess(proposalType, "pawnId"); + proposalWt = AccessTools.FieldRefAccess(proposalType, "wt"); + proposalFrom = AccessTools.FieldRefAccess(proposalType, "from"); + proposalTo = AccessTools.FieldRefAccess(proposalType, "to"); + proposalConf = AccessTools.FieldRefAccess(proposalType, "conf"); + proposalAff = AccessTools.FieldRefAccess(proposalType, "aff"); + proposalEvCount = AccessTools.FieldRefAccess(proposalType, "evCount"); + proposalSkill = AccessTools.FieldRefAccess(proposalType, "skill"); + proposalPassion = AccessTools.FieldRefAccess(proposalType, "passion"); + proposalCoverage = AccessTools.FieldRefAccess(proposalType, "coverage"); + proposalDemote = AccessTools.FieldRefAccess(proposalType, "demote"); + proposalColdDays = AccessTools.FieldRefAccess(proposalType, "coldDays"); + proposalCellKeyProp = AccessTools.Property(proposalType, "CellKey"); + + // PawnProfile Accessors + pawnProfileIdField = AccessTools.FieldRefAccess(pawnProfileType, "pawnId"); + pawnProfileModeField = AccessTools.FieldRefAccess(pawnProfileType, "mode"); + pawnProfileLockedField = AccessTools.FieldRefAccess>(pawnProfileType, "locked"); + + // PriorityWriter Accessors + priorityWriterApplyMethod = AccessTools.Method(priorityWriterType, "Apply", new[] { typeof(Pawn), typeof(WorkTypeDef), typeof(int), typeof(bool), typeof(bool) }); + priorityWriterApplyEmergencyMethod = AccessTools.Method(priorityWriterType, "ApplyEmergency", new[] { typeof(Pawn), typeof(WorkTypeDef), typeof(int) }); + priorityWriterEnsureNumericModeMethod = AccessTools.Method(priorityWriterType, "EnsureNumericMode"); + + // EmergencyManager Accessors + emergencyManagerTickMethod = AccessTools.Method(emergencyManagerType, "Tick", new[] { typeof(int) }); + emergencyManagerReleaseAllMethod = AccessTools.Method(emergencyManagerType, "ReleaseAll", new[] { typeof(string) }); + emergencyManagerCoveredCountMethod = AccessTools.Method(emergencyManagerType, "CoveredCount", new[] { typeof(Map), typeof(WorkTypeDef) }); + emergencyManagerPickFromChainMethod = AccessTools.Method(emergencyManagerType, "PickFromChain"); + emergencyManagerReconcileMethod = AccessTools.Method(emergencyManagerType, "Reconcile"); + emergencyManagerRefreshStatesMethod = AccessTools.Method(emergencyManagerType, "RefreshStates"); + emergencyActiveStatesField = AccessTools.StaticFieldRefAccess(AccessTools.Field(emergencyManagerType, "ActiveStates")); + + // ActiveCover Accessors + activeCoverPawnId = AccessTools.FieldRefAccess(activeCoverType, "pawnId"); + activeCoverWt = AccessTools.FieldRefAccess(activeCoverType, "wt"); + activeCoverPrev = AccessTools.FieldRefAccess(activeCoverType, "prev"); + activeCoverApplied = AccessTools.FieldRefAccess(activeCoverType, "applied"); + activeCoverSource = AccessTools.FieldRefAccess(activeCoverType, "source"); + activeCoverStateDef = AccessTools.FieldRefAccess(activeCoverType, "stateDef"); + + // Settings & Misc Accessors + awModSettingsField = AccessTools.Field(awModType, "Settings"); + settingsMasterField = AccessTools.FieldRefAccess(awSettingsType, "master"); + settingsModeField = AccessTools.FieldRefAccess(awSettingsType, "mode"); + settingsEmergencyCoverField = AccessTools.FieldRefAccess(awSettingsType, "emergencyCover"); + settingsEmergencyTriageField = AccessTools.FieldRefAccess(awSettingsType, "emergencyTriage"); + settingsWriteIntervalTicksField = AccessTools.FieldRefAccess(awSettingsType, "writeIntervalTicks"); + settingsWritesPerPassField = AccessTools.FieldRefAccess(awSettingsType, "writesPerPass"); + settingsSemiConfidenceField = AccessTools.FieldRefAccess(awSettingsType, "semiConfidence"); + settingsToastsSuggestField = AccessTools.FieldRefAccess(awSettingsType, "toastsSuggest"); + settingsToastsLearningField = AccessTools.FieldRefAccess(awSettingsType, "toastsLearning"); + writeSettingsMethod = AccessTools.Method(typeof(ModSettings), nameof(ModSettings.Write)); + dropCellToastMethod = AccessTools.Method(toastsType, "DropCell", new[] { typeof(long) }); + emergencyCoverTuningMethod = AccessTools.Method(workTypeTuningDefType, "EmergencyCover", new[] { typeof(WorkTypeDef) }); + demandKindTuningMethod = AccessTools.Method(workTypeTuningDefType, "DemandKind", new[] { typeof(WorkTypeDef) }); + priorityRangesMaxProp = AccessTools.Property(priorityRangesType, "Max"); + emergencyViewSelField = AccessTools.StaticFieldRefAccess(AccessTools.Field(emergencyViewType, "sel")); + stateDefProfileField = AccessTools.FieldRefAccess(emergencyStateDefType, "profile"); + stateDefOrderField = AccessTools.FieldRefAccess(emergencyStateDefType, "order"); + profileEntryWorkTypeField = AccessTools.FieldRefAccess(emergencyProfileEntryType, "workType"); + profileEntryPriorityField = AccessTools.FieldRefAccess(emergencyProfileEntryType, "priority"); + + // Register Sync Workers + MP.RegisterSyncWorker(SyncLearningStore, learningStoreType); + MP.RegisterSyncWorker(SyncProposal, proposalType, shouldConstruct: true); + + // Register Sync Methods - Builtin + if (learningStoreApplyProposalMethod != null) MP.RegisterSyncMethod(learningStoreApplyProposalMethod); + if (learningStoreSnoozeMethod != null) MP.RegisterSyncMethod(learningStoreSnoozeMethod); + if (learningStoreNeverMethod != null) MP.RegisterSyncMethod(learningStoreNeverMethod); + if (learningStoreResetLearningMethod != null) MP.RegisterSyncMethod(learningStoreResetLearningMethod); + if (learningStoreUndoLastMethod != null) MP.RegisterSyncMethod(learningStoreUndoLastMethod); + if (priorityWriterEnsureNumericModeMethod != null) + { + MP.RegisterSyncMethod(priorityWriterEnsureNumericModeMethod); + MpCompat.harmony.Patch( + priorityWriterEnsureNumericModeMethod, + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostfixEnsureNumericMode))); + } + + // Register Sync Methods - Compatibility Handlers + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedCyclePawnMode)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedSetPawnMode)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedSetGlobalMode)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedSetMaster)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedToggleLock)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedSetChain)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedApplyAll)); + MP.RegisterSyncMethod(typeof(AdaptiveWorkPriorities), nameof(SyncedApplyManual)); + + // Harmony Patches - PriorityWriter Manual Sync Interception + if (priorityWriterApplyMethod != null) + { + MpCompat.harmony.Patch( + priorityWriterApplyMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixPriorityWriterApply))); + } + + // Harmony Patches - Toasts Deterministic Ticks Throttling + if (toastsType != null) + { + var notifySignalMethod = AccessTools.Method(toastsType, "NotifySignal"); + if (notifySignalMethod != null) + { + MpCompat.harmony.Patch( + notifySignalMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixNotifySignal))); + } + + var notifyAppliedMethod = AccessTools.Method(toastsType, "NotifyApplied"); + if (notifyAppliedMethod != null) + { + MpCompat.harmony.Patch( + notifyAppliedMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixNotifyApplied))); + } + } + + // Harmony Patches - LearningStore Multi-Map Determinism + MpCompat.harmony.Patch( + AccessTools.Method(learningStoreType, "GameComponentTick"), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixLearningStoreTick))); + + MpCompat.harmony.Patch( + AccessTools.Method(learningStoreType, "Coverage", new[] { typeof(WorkTypeDef), typeof(bool) }), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixLearningStoreCoverage))); + + MpCompat.harmony.Patch( + AccessTools.Method(learningStoreType, "MarkAllDirty"), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixLearningStoreMarkAllDirty))); + + // Harmony Patches - Emergency Multi-Map Evaluation + MpCompat.harmony.Patch( + AccessTools.Method(emergencyManagerType, "Evaluate"), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixEmergencyManagerEvaluate))); + + MpCompat.harmony.Patch( + AccessTools.Method(demandProbeType, "Refresh", new[] { typeof(Map) }), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixDemandProbeRefresh))); + + MpCompat.harmony.Patch( + AccessTools.Method(demandProbeType, "HasDemand", new[] { typeof(Map), typeof(WorkTypeDef) }), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixDemandProbeHasDemand))); + + // Harmony Patches - UI Interceptions + MpCompat.harmony.Patch( + AccessTools.Method(pawnProfileType, "ToggleLock", new[] { typeof(WorkTypeDef) }), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixPawnProfileToggleLock))); + + MpCompat.harmony.Patch( + AccessTools.Method(mainWindowType, "ApplyAllVisible"), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PrefixApplyAllVisible))); + + MpCompat.harmony.Patch( + AccessTools.Method(emergencyViewType, "DrawChainEditor"), + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PreDrawChainEditor)), + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostDrawChainEditor))); + + if (mainWindowType != null) + { + var drawHeaderMethod = AccessTools.Method(mainWindowType, "DrawHeader", new[] { typeof(Rect) }); + if (drawHeaderMethod != null) + { + MpCompat.harmony.Patch( + drawHeaderMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PreDrawHeader)), + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostDrawHeader))); + } + else + { + var doWindowContentsMethod = AccessTools.Method(mainWindowType, "DoWindowContents", new[] { typeof(Rect) }); + if (doWindowContentsMethod != null) + { + MpCompat.harmony.Patch( + doWindowContentsMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PreDrawHeader)), + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostDrawHeader))); + } + } + } + + if (awSettingsType != null) + { + var doWindowContentsMethod = AccessTools.Method(awSettingsType, "DoWindowContents", new[] { typeof(Rect) }); + if (doWindowContentsMethod != null) + { + MpCompat.harmony.Patch( + doWindowContentsMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PreDrawHeader)), + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostDrawHeader))); + } + } + + if (pawnCardType != null) + { + var drawIdentityMethod = AccessTools.Method(pawnCardType, "DrawIdentity"); + if (drawIdentityMethod != null) + { + MpCompat.harmony.Patch( + drawIdentityMethod, + prefix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PreDrawIdentity)), + postfix: new HarmonyMethod(typeof(AdaptiveWorkPriorities), nameof(PostDrawIdentity))); + } + } + } + + #region Sync Workers + + private static void SyncLearningStore(SyncWorker sync, ref object store) + { + if (!sync.isWriting) + { + store = GetLearningStore(); + } + } + + private static void SyncProposal(SyncWorker sync, ref object obj) + { + if (sync.isWriting) + { + sync.Write(proposalPawnId(obj)); + sync.Write(proposalWt(obj)); + sync.Write(proposalFrom(obj)); + sync.Write(proposalTo(obj)); + sync.Write(proposalConf(obj)); + sync.Write(proposalAff(obj)); + sync.Write(proposalEvCount(obj)); + sync.Write(proposalSkill(obj)); + sync.Write(proposalPassion(obj)); + sync.Write(proposalCoverage(obj)); + sync.Write(proposalDemote(obj)); + sync.Write(proposalColdDays(obj)); + } + else + { + proposalPawnId(obj) = sync.Read(); + proposalWt(obj) = sync.Read(); + proposalFrom(obj) = sync.Read(); + proposalTo(obj) = sync.Read(); + proposalConf(obj) = sync.Read(); + proposalAff(obj) = sync.Read(); + proposalEvCount(obj) = sync.Read(); + proposalSkill(obj) = sync.Read(); + proposalPassion(obj) = sync.Read(); + proposalCoverage(obj) = sync.Read(); + proposalDemote(obj) = sync.Read(); + proposalColdDays(obj) = sync.Read(); + } + } + + #endregion + + #region Synced Methods + + [SyncMethod] + public static void SyncedCyclePawnMode(Pawn pawn) + { + var store = GetLearningStore(); + if (store == null || pawn == null) return; + var profile = learningStoreProfileForMethod?.Invoke(store, new object[] { pawn }); + if (profile == null) return; + int curMode = pawnProfileModeField(profile); + byte nextMode = (byte)((curMode + 1) % 3); + pawnProfileModeField(profile) = nextMode; + learningStoreMarkDirtyMethod?.Invoke(store, new object[] { pawn }); + } + + [SyncMethod] + public static void SyncedSetPawnMode(Pawn pawn, int mode) + { + var store = GetLearningStore(); + if (store == null || pawn == null) return; + var profile = learningStoreProfileForMethod?.Invoke(store, new object[] { pawn }); + if (profile == null) return; + pawnProfileModeField(profile) = (byte)mode; + learningStoreMarkDirtyMethod?.Invoke(store, new object[] { pawn }); + } + + [SyncMethod] + public static void SyncedSetGlobalMode(int mode) + { + var settings = GetSettings(); + if (settings == null) return; + settingsModeField(settings) = mode; + writeSettingsMethod?.Invoke(settings, null); + var store = GetLearningStore(); + if (store != null) + learningStoreMarkAllDirtyMethod?.Invoke(store, null); + Log.Message($"[MpCompat] AdaptiveWorkPriorities: SyncedSetGlobalMode applied mode={mode}"); + } + + [SyncMethod] + public static void SyncedSetMaster(bool master) + { + var settings = GetSettings(); + if (settings == null) return; + settingsMasterField(settings) = master; + writeSettingsMethod?.Invoke(settings, null); + } + + [SyncMethod] + public static void SyncedToggleLock(Pawn pawn, WorkTypeDef wt) + { + var store = GetLearningStore(); + if (store == null || pawn == null || wt == null) return; + var profile = learningStoreProfileForMethod?.Invoke(store, new object[] { pawn }); + if (profile == null) return; + var lockedList = pawnProfileLockedField(profile); + if (lockedList == null) return; + + if (lockedList.Contains(wt.defName)) + lockedList.Remove(wt.defName); + else + lockedList.Add(wt.defName); + + learningStoreMarkDirtyMethod?.Invoke(store, new object[] { pawn }); + } + + [SyncMethod] + public static void SyncedSetChain(WorkTypeDef wt, List pawnIds) + { + var store = GetLearningStore(); + if (store == null || wt == null) return; + var chain = learningStoreChainForMethod?.Invoke(store, new object[] { wt }) as List; + if (chain == null) return; + chain.Clear(); + if (pawnIds != null) + chain.AddRange(pawnIds); + } + + [SyncMethod] + public static void SyncedApplyAll(Map map, int cohortInt) + { + var store = GetLearningStore(); + if (store == null || map == null) return; + + var cohortsType = AccessTools.TypeByName("AdaptiveWork.UI.Cohorts"); + var rosterMethod = AccessTools.Method(cohortsType, "Roster"); + if (rosterMethod == null) return; + + var roster = rosterMethod.Invoke(null, new object[] { cohortInt == 1 ? 1 : 0, map }) as List; + if (roster == null) return; + + for (int i = 0; i < roster.Count; i++) + { + var proposals = learningStoreProposalsForMethod?.Invoke(store, new object[] { roster[i] }) as IList; + if (proposals == null) continue; + + for (int num = proposals.Count - 1; num >= 0; num--) + { + learningStoreApplyProposalMethod?.Invoke(store, new object[] { roster[i], proposals[num], true }); + } + } + } + + private static void PostfixEnsureNumericMode() + { + if (Current.Game?.playSettings == null) return; + foreach (Pawn item in PawnsFinder.AllMapsWorldAndTemporary_Alive) + { + if (item.Faction == Faction.OfPlayer && item.workSettings != null) + { + item.workSettings.Notify_UseWorkPrioritiesChanged(); + } + } + learningStoreMarkAllDirtyMethod?.Invoke(learningStoreInstanceProp?.GetValue(null), null); + } + + [SyncMethod] + public static void SyncedApplyManual(Pawn pawn, WorkTypeDef wt, int to) + { + priorityWriterApplyMethod?.Invoke(null, new object[] { pawn, wt, to, true, true }); + } + + #endregion + + #region Multi-Map Determinism Patches + + private static bool PrefixLearningStoreTick(object __instance) + { + if (!MP.IsInMultiplayer) + return true; + + var settings = GetSettings(); + if (settings == null || !settingsMasterField(settings)) + return false; + + int ticksGame = Find.TickManager.TicksGame; + if (ticksGame % 1000 == 0) + { + learningStoreTryEmitDigestMethod?.Invoke(__instance, new object[] { ticksGame }); + } + + emergencyManagerTickMethod?.Invoke(null, new object[] { ticksGame }); + + int mode = settingsModeField(settings); + // 0: Observe, 1: Suggest, 2: Semi, 3: Auto + if (mode != 2 && mode != 3) + return false; + + int interval = Mathf.Max(60, settingsWriteIntervalTicksField(settings)); + int lastPass = lastWriterPassAtField(__instance); + if (lastPass >= 0 && ticksGame - lastPass < interval && lastPass <= ticksGame) + return false; + + lastWriterPassAtField(__instance) = ticksGame; + + Faction ofPlayer = Faction.OfPlayerSilentFail; + if (ofPlayer == null) + return false; + + var schedulerRoster = schedulerRosterField(__instance); + schedulerRoster.Clear(); + + // Gather colonists across all player home maps deterministically + var playerMaps = Find.Maps.Where(m => m.IsPlayerHome).OrderBy(m => m.uniqueID).ToList(); + if (playerMaps.Count == 0) + { + playerMaps = Find.Maps.Where(m => m.mapPawns.SpawnedPawnsInFaction(ofPlayer).Count > 0) + .OrderBy(m => m.uniqueID).ToList(); + } + + foreach (var map in playerMaps) + { + var spawned = map.mapPawns.SpawnedPawnsInFaction(ofPlayer); + spawned.SortBy(p => p.thingIDNumber); + for (int i = 0; i < spawned.Count; i++) + { + bool tracks = (bool)learningStoreTracksMethod.Invoke(null, new object[] { spawned[i] }); + if (tracks) + schedulerRoster.Add(spawned[i]); + } + } + + if (schedulerRoster.Count == 0) + return false; + + int bucket = (writerBucketField(__instance) + 1) % schedulerRoster.Count; + writerBucketField(__instance) = bucket; + + Pawn pawn = (Pawn)schedulerRoster[bucket]; + var profile = learningStoreProfileForMethod?.Invoke(__instance, new object[] { pawn }); + if (profile == null || pawnProfileModeField(profile) != 0) // 0: Adaptive + return false; + + try + { + currentEvaluatingMap = pawn.MapHeld ?? pawn.Map; + + var proposals = learningStoreProposalsForMethod?.Invoke(__instance, new object[] { pawn }) as IList; + if (proposals == null || proposals.Count == 0) + return false; + + int writesAllowed = settingsWritesPerPassField(settings); + float semiConf = settingsSemiConfidenceField(settings); + + int idx = proposals.Count - 1; + while (idx >= 0 && writesAllowed > 0) + { + var p = proposals[idx]; + float conf = proposalConf(p); + if (mode != 2 || conf >= semiConf) + { + var wt = proposalWt(p); + int to = proposalTo(p); + bool applied = (bool)priorityWriterApplyMethod.Invoke(null, new object[] { pawn, wt, to, false, true }); + if (applied) + { + proposals.RemoveAt(idx); + long cellKey = (long)proposalCellKeyProp.GetValue(p); + dropCellToastMethod?.Invoke(null, new object[] { cellKey }); + writesAllowed--; + } + } + idx--; + } + } + finally + { + currentEvaluatingMap = null; + } + + return false; + } + + private static bool PrefixLearningStoreCoverage(WorkTypeDef wt, bool mechs, ref int __result) + { + if (!MP.IsInMultiplayer) + return true; + + Map map = currentEvaluatingMap ?? Find.CurrentMap; + if (map == null || wt == null) + { + __result = 0; + return false; + } + + int now = Find.TickManager.TicksGame; + if (now - lastCoverageTick > 250 || lastCoverageTick > now) + { + multiMapCoverage.Clear(); + lastCoverageTick = now; + } + + int mechsInt = mechs ? 1 : 0; + if (!multiMapCoverage.TryGetValue((map.uniqueID, mechsInt, wt.index), out __result)) + { + // Compute coverage for all work types on this map + var list = mechs ? map.mapPawns.SpawnedColonyMechs : map.mapPawns.FreeColonists; + var allDefs = DefDatabase.AllDefsListForReading; + + for (int d = 0; d < allDefs.Count; d++) + { + multiMapCoverage[(map.uniqueID, mechsInt, allDefs[d].index)] = 0; + } + + for (int i = 0; i < list.Count; i++) + { + Pawn p = list[i]; + if (p?.workSettings == null || !p.workSettings.EverWork) + continue; + + for (int j = 0; j < allDefs.Count; j++) + { + WorkTypeDef def = allDefs[j]; + if (p.workSettings.GetPriority(def) > 0 && !p.WorkTypeIsDisabled(def)) + { + multiMapCoverage.TryGetValue((map.uniqueID, mechsInt, def.index), out int count); + multiMapCoverage[(map.uniqueID, mechsInt, def.index)] = count + 1; + } + } + } + + multiMapCoverage.TryGetValue((map.uniqueID, mechsInt, wt.index), out __result); + } + + return false; + } + + private static bool PrefixLearningStoreMarkAllDirty(object __instance) + { + if (!MP.IsInMultiplayer) + return true; + + Faction ofPlayer = Faction.OfPlayerSilentFail; + if (ofPlayer == null) + return false; + + var dirtySet = storeDirtyField(__instance); + foreach (var map in Find.Maps) + { + var pawns = map.mapPawns.SpawnedPawnsInFaction(ofPlayer); + for (int i = 0; i < pawns.Count; i++) + { + bool tracks = (bool)learningStoreTracksMethod.Invoke(null, new object[] { pawns[i] }); + if (tracks) + dirtySet.Add(pawns[i].thingIDNumber); + } + } + + return false; + } + + private static bool PrefixEmergencyManagerEvaluate() + { + if (!MP.IsInMultiplayer) + return true; + + var settings = GetSettings(); + var instance = GetLearningStore(); + if (instance == null || Find.Maps.Count == 0) + { + emergencyManagerReleaseAllMethod?.Invoke(null, new object[] { null }); + return false; + } + + if (settings == null || !settingsEmergencyCoverField(settings)) + { + emergencyManagerReleaseAllMethod?.Invoke(null, new object[] { "feature off" }); + return false; + } + + var homeMaps = Find.Maps.Where(m => m.IsPlayerHome).OrderBy(m => m.uniqueID).ToList(); + if (homeMaps.Count == 0) + { + homeMaps = Find.Maps.Where(m => m.mapPawns.SpawnedPawnsInFaction(Faction.OfPlayerSilentFail).Count > 0) + .OrderBy(m => m.uniqueID).ToList(); + } + + if (homeMaps.Count == 0) + { + emergencyManagerReleaseAllMethod?.Invoke(null, new object[] { null }); + return false; + } + + var wanted = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(activeCoverType)); + var activeStatesList = emergencyActiveStatesField(); + var accumulatedStates = new List(); + + var allDefs = DefDatabase.AllDefsListForReading; + int maxPriority = (int)priorityRangesMaxProp.GetValue(null); + + foreach (var map in homeMaps) + { + // Refresh demand probe & states for map + PrefixDemandProbeRefresh(map); + emergencyManagerRefreshStatesMethod?.Invoke(null, new object[] { map, settings }); + + // Accumulate states for UI + for (int s = 0; s < activeStatesList.Count; s++) + { + var st = activeStatesList[s]; + if (!accumulatedStates.Contains(st)) + accumulatedStates.Add(st); + } + + // Emergency Cover evaluation + for (int i = 0; i < allDefs.Count; i++) + { + WorkTypeDef wt = allDefs[i]; + bool ec = (bool)emergencyCoverTuningMethod.Invoke(null, new object[] { wt }); + bool hasDemand = false; + PrefixDemandProbeHasDemand(map, wt, ref hasDemand); + int coveredCount = (int)emergencyManagerCoveredCountMethod.Invoke(null, new object[] { map, wt }); + + if (ec && hasDemand && coveredCount <= 0) + { + object[] pickArgs = new object[] { map, instance, wt, (byte)0 }; + Pawn picked = (Pawn)emergencyManagerPickFromChainMethod.Invoke(null, pickArgs); + byte source = (byte)pickArgs[3]; + + if (picked != null) + { + var cover = Activator.CreateInstance(activeCoverType); + activeCoverPawnId(cover) = picked.thingIDNumber; + activeCoverWt(cover) = wt.defName; + activeCoverApplied(cover) = 1; + activeCoverSource(cover) = source; + wanted.Add(cover); + } + } + } + + // Emergency Triage evaluation + if (settingsEmergencyTriageField(settings) && activeStatesList.Count > 0) + { + var freeColonists = map.mapPawns.FreeColonistsSpawned; + for (int j = 0; j < activeStatesList.Count; j++) + { + var stateDef = activeStatesList[j]; + var profileList = stateDefProfileField(stateDef); + if (profileList == null) continue; + + for (int k = 0; k < profileList.Count; k++) + { + var entry = profileList[k]; + string workTypeName = profileEntryWorkTypeField(entry); + WorkTypeDef wt = DefDatabase.GetNamedSilentFail(workTypeName); + if (wt == null) continue; + + for (int l = 0; l < freeColonists.Count; l++) + { + Pawn colonist = freeColonists[l]; + bool tracks = (bool)learningStoreTracksMethod.Invoke(null, new object[] { colonist }); + if (tracks && !colonist.WorkTypeIsDisabled(wt) && !colonist.Downed && !colonist.InMentalState) + { + int desiredPrio = Mathf.Clamp(profileEntryPriorityField(entry), 0, maxPriority); + if (colonist.workSettings.GetPriority(wt) != desiredPrio && + !CoverExistsInList(wanted, colonist.thingIDNumber, wt.defName)) + { + var cover = Activator.CreateInstance(activeCoverType); + activeCoverPawnId(cover) = colonist.thingIDNumber; + activeCoverWt(cover) = wt.defName; + activeCoverApplied(cover) = desiredPrio; + activeCoverSource(cover) = 2; // Triage + activeCoverStateDef(cover) = ((Def)stateDef).defName; + wanted.Add(cover); + } + } + } + } + } + } + } + + // Restore sorted accumulated states to EmergencyManager.ActiveStates for UI rendering + activeStatesList.Clear(); + accumulatedStates.Sort((a, b) => stateDefOrderField(a).CompareTo(stateDefOrderField(b))); + for (int i = 0; i < accumulatedStates.Count; i++) + activeStatesList.Add(accumulatedStates[i]); + + emergencyManagerReconcileMethod?.Invoke(null, new object[] { instance, wanted }); + + return false; + } + + private static bool CoverExistsInList(IList list, int pawnId, string wt) + { + for (int i = 0; i < list.Count; i++) + { + var item = list[i]; + if (activeCoverPawnId(item) == pawnId && activeCoverWt(item) == wt) + return true; + } + return false; + } + + private static bool PrefixDemandProbeRefresh(Map map) + { + if (!MP.IsInMultiplayer) + return true; + + if (map == null) + return false; + + int ticks = Find.TickManager.TicksGame; + if (ticks != multiMapDemandTick) + { + multiMapDemandTick = ticks; + multiMapDemand.Clear(); + } + + multiMapDemand[(map.uniqueID, "Tending")] = AnyNeedsTending(map); + multiMapDemand[(map.uniqueID, "Fire")] = map.listerThings.ThingsOfDef(ThingDefOf.Fire).Count > 0; + multiMapDemand[(map.uniqueID, "Food")] = AnyHungry(map); + multiMapDemand[(map.uniqueID, "Prisoners")] = map.mapPawns.PrisonersOfColonySpawned.Count > 0; + multiMapDemand[(map.uniqueID, "Always")] = true; + + return false; + } + + private static bool PrefixDemandProbeHasDemand(Map map, WorkTypeDef wt, ref bool __result) + { + if (!MP.IsInMultiplayer) + return true; + + if (map == null || wt == null) + { + __result = false; + return false; + } + + string kind = (string)demandKindTuningMethod?.Invoke(null, new object[] { wt }); + if (!string.IsNullOrEmpty(kind) && multiMapDemand.TryGetValue((map.uniqueID, kind), out bool val)) + { + __result = val; + } + else + { + __result = false; + } + return false; + } + + private static bool AnyNeedsTending(Map map) + { + try + { + var list = map.mapPawns.FreeColonistsAndPrisonersSpawned; + for (int i = 0; i < list.Count; i++) + { + if (HealthAIUtility.ShouldBeTendedNowByPlayer(list[i])) + return true; + } + } + catch + { + } + return false; + } + + private static bool AnyHungry(Map map) + { + try + { + var list = map.mapPawns.FreeColonistsSpawned; + for (int i = 0; i < list.Count; i++) + { + Need_Food food = list[i].needs?.food; + if (food != null && food.CurLevelPercentage < 0.32f) + return true; + } + } + catch + { + } + return false; + } + + #endregion + + #region UI Interceptions + + private static int lastAppliedAtTick = -99999; + private static int lastLearnAtTick = -99999; + + private static bool PrefixPriorityWriterApply(Pawn pawn, WorkTypeDef wt, int to, bool manualGesture, bool recordUndo) + { + if (!MP.IsInMultiplayer) + return true; + + if (MP.InInterface && manualGesture) + { + SyncedApplyManual(pawn, wt, to); + return false; + } + + return true; + } + + private static bool PrefixNotifySignal(Pawn pawn, WorkTypeDef wt, object kind, string label) + { + if (!MP.IsInMultiplayer) + return true; + + try + { + var settings = GetSettings(); + int ticksGame = Find.TickManager?.TicksGame ?? 0; + int kindInt = kind != null ? Convert.ToInt32(kind) : 0; + if (settings != null && settingsToastsLearningField != null && settingsToastsLearningField(settings) && kindInt != 2 && kindInt != 3 && (ticksGame - lastLearnAtTick >= 300 || lastLearnAtTick > ticksGame)) + { + lastLearnAtTick = ticksGame; + if (pawn != null && wt != null) + { + string wtLabel = wt.labelShort.NullOrEmpty() ? wt.label : wt.labelShort; + Messages.Message("AW.Msg.Learned".Translate(pawn.LabelShortCap, wtLabel), pawn, MessageTypeDefOf.SilentInput, false); + } + } + } + catch (Exception ex) + { + Log.Warning($"[MpCompat] AdaptiveWorkPriorities: Error in PrefixNotifySignal: {ex}"); + } + + return false; + } + + private static bool PrefixNotifyApplied(Pawn pawn, WorkTypeDef wt, int from, int to) + { + if (!MP.IsInMultiplayer) + return true; + + try + { + var settings = GetSettings(); + int ticksGame = Find.TickManager?.TicksGame ?? 0; + if (settings != null && settingsToastsSuggestField != null && settingsToastsSuggestField(settings) && (ticksGame - lastAppliedAtTick >= 240 || lastAppliedAtTick > ticksGame)) + { + lastAppliedAtTick = ticksGame; + if (pawn != null && wt != null) + { + string wtLabel = wt.labelShort.NullOrEmpty() ? wt.label : wt.labelShort; + string fromStr = from == 0 ? (string)"AW.Off".Translate() : from.ToString(); + string toStr = to == 0 ? (string)"AW.Off".Translate() : to.ToString(); + Messages.Message("AW.Msg.Applied".Translate(pawn.LabelShortCap, wtLabel, fromStr, toStr), pawn, MessageTypeDefOf.SilentInput, false); + } + } + } + catch (Exception ex) + { + Log.Warning($"[MpCompat] AdaptiveWorkPriorities: Error in PrefixNotifyApplied: {ex}"); + } + + return false; + } + + private static bool PrefixPawnProfileToggleLock(object __instance, WorkTypeDef wt) + { + if (!MP.IsInMultiplayer || MP.IsExecutingSyncCommand) + return true; + + int pawnId = pawnProfileIdField(__instance); + Pawn pawn = LearningStoreFindPawn(pawnId); + if (pawn != null && wt != null) + { + SyncedToggleLock(pawn, wt); + } + + return false; + } + + private static bool PrefixApplyAllVisible(object cohort) + { + if (!MP.IsInMultiplayer) + return true; + + try + { + int cohortInt = cohort != null ? Convert.ToInt32(cohort) : 0; + SyncedApplyAll(Find.CurrentMap, cohortInt); + } + catch (Exception ex) + { + Log.Warning($"[MpCompat] AdaptiveWorkPriorities: Error in PrefixApplyAllVisible: {ex}"); + } + + return false; + } + + private static void PreDrawChainEditor(object store, ref (WorkTypeDef wt, List snapshot) __state) + { + if (!MP.IsInMultiplayer || store == null) return; + var curSel = emergencyViewSelField(); + if (curSel != null) + { + var chain = learningStoreChainForMethod?.Invoke(store, new object[] { curSel }) as List; + if (chain != null) + { + __state = (curSel, new List(chain)); + } + } + } + + private static void PostDrawChainEditor(object store, (WorkTypeDef wt, List snapshot) __state) + { + if (!MP.IsInMultiplayer || store == null || __state.wt == null || __state.snapshot == null) return; + var chain = learningStoreChainForMethod?.Invoke(store, new object[] { __state.wt }) as List; + if (chain != null && !chain.SequenceEqual(__state.snapshot)) + { + var newChain = new List(chain); + // Revert local mutation so that sync command applies it deterministically across all clients + chain.Clear(); + chain.AddRange(__state.snapshot); + SyncedSetChain(__state.wt, newChain); + } + } + + private static void PreDrawHeader(out (int mode, bool master) __state) + { + try + { + var settings = GetSettings(); + if (settings != null && settingsModeField != null && settingsMasterField != null) + { + __state = (settingsModeField(settings), settingsMasterField(settings)); + return; + } + } + catch (Exception ex) + { + Log.Warning($"[MpCompat] AdaptiveWorkPriorities: Error in PreDrawHeader: {ex}"); + } + __state = (-1, false); + } + + private static void PostDrawHeader((int mode, bool master) __state) + { + if (!MP.IsInMultiplayer || __state.mode == -1) return; + try + { + var settings = GetSettings(); + if (settings == null || settingsModeField == null || settingsMasterField == null) return; + + int curMode = settingsModeField(settings); + if (curMode != __state.mode) + { + Log.Message($"[MpCompat] AdaptiveWorkPriorities: Global mode changed from {__state.mode} to {curMode}, syncing."); + settingsModeField(settings) = __state.mode; + SyncedSetGlobalMode(curMode); + } + + bool curMaster = settingsMasterField(settings); + if (curMaster != __state.master) + { + settingsMasterField(settings) = __state.master; + SyncedSetMaster(curMaster); + } + } + catch (Exception ex) + { + Log.Warning($"[MpCompat] AdaptiveWorkPriorities: Error in PostDrawHeader: {ex}"); + } + } + + private static void PreDrawIdentity(Pawn pawn, object store, out int __state) + { + __state = -1; + if (!MP.IsInMultiplayer || pawn == null || store == null) return; + var profile = learningStoreProfileForMethod?.Invoke(store, new object[] { pawn }); + if (profile != null) + { + __state = pawnProfileModeField(profile); + } + } + + private static void PostDrawIdentity(Pawn pawn, object store, int __state) + { + if (!MP.IsInMultiplayer || pawn == null || store == null || __state == -1) return; + var profile = learningStoreProfileForMethod?.Invoke(store, new object[] { pawn }); + if (profile != null) + { + int curMode = pawnProfileModeField(profile); + if (curMode != __state) + { + pawnProfileModeField(profile) = (byte)__state; + SyncedSetPawnMode(pawn, curMode); + } + } + } + + #endregion + + #region Helpers + + private static object GetLearningStore() + { + return learningStoreInstanceProp?.GetValue(null); + } + + private static object GetSettings() + { + var settings = awModSettingsField?.GetValue(null); + if (settings != null) + return settings; + + if (awModType != null && awSettingsType != null) + { + var mod = LoadedModManager.GetMod(awModType); + if (mod != null) + { + var getSettingsMethod = AccessTools.Method(typeof(Mod), "GetSettings").MakeGenericMethod(awSettingsType); + return getSettingsMethod?.Invoke(mod, null); + } + } + + return null; + } + + private static Pawn LearningStoreFindPawn(int id) + { + if (learningStoreFindPawnMethod != null) + return (Pawn)learningStoreFindPawnMethod.Invoke(null, new object[] { id }); + + Faction playerFaction = Faction.OfPlayerSilentFail; + if (playerFaction == null) return null; + var maps = Find.Maps; + for (int i = 0; i < maps.Count; i++) + { + var list = maps[i].mapPawns.SpawnedPawnsInFaction(playerFaction); + for (int j = 0; j < list.Count; j++) + { + if (list[j].thingIDNumber == id) + return list[j]; + } + } + return null; + } + + #endregion + } +} diff --git a/Source/Mods/Flourish.cs b/Source/Mods/Flourish.cs new file mode 100644 index 0000000..73b6b68 --- /dev/null +++ b/Source/Mods/Flourish.cs @@ -0,0 +1,42 @@ +using System; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Compat +{ + /// Flourish by Astryl + /// + [MpCompatFor("astryl.flourish")] + internal class Flourish + { + public Flourish(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var tryIdleFlavour = AccessTools.DeclaredMethod("Flourish.FlourishGame:TryIdleFlavour"); + if (tryIdleFlavour != null) + { + MpCompat.harmony.Patch( + tryIdleFlavour, + prefix: new HarmonyMethod(typeof(Flourish), nameof(TryIdleFlavour_Prefix)), + finalizer: new HarmonyMethod(typeof(Flourish), nameof(TryIdleFlavour_Finalizer)) + ); + } + else + { + Log.Warning("[Multiplayer Compat] Flourish: Could not find FlourishGame.TryIdleFlavour method."); + } + } + + // Isolate Rand state during camera-dependent idle flair checks so the shared simulation seed is never desynchronized + private static void TryIdleFlavour_Prefix() + { + Rand.PushState(); + } + + private static void TryIdleFlavour_Finalizer() + { + Rand.PopState(); + } + } +} diff --git a/Source/Mods/ModernCC.cs b/Source/Mods/ModernCC.cs new file mode 100644 index 0000000..119bc27 --- /dev/null +++ b/Source/Mods/ModernCC.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Character Creator by Astryl + /// + [MpCompatFor("astryl.moderncc")] + [MpCompatFor("astryl.ModernCC")] + internal class ModernCC + { + private static MethodInfo teleportMethod; + + public ModernCC(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + // 1. Block opening Character Editor mid-game in multiplayer to prevent raw in-memory mutations + var editorType = AccessTools.TypeByName("MCE.Window_CharacterEditor"); + if (editorType != null) + { + var toggleEditor = AccessTools.Method(editorType, "ToggleEditor"); + if (toggleEditor != null) + { + MpCompat.harmony.Patch( + toggleEditor, + prefix: new HarmonyMethod(typeof(ModernCC), nameof(PrefixBlockInMultiplayer))); + } + + var doWindowContents = AccessTools.Method(editorType, "DoWindowContents", new[] { typeof(Rect) }); + if (doWindowContents != null) + { + MpCompat.harmony.Patch( + doWindowContents, + prefix: new HarmonyMethod(typeof(ModernCC), nameof(PrefixBlockWindowContents))); + } + } + + // 2. Synchronize pawn skipping/teleportation + var teleportType = AccessTools.TypeByName("MCE.MCETeleport"); + if (teleportType != null) + { + teleportMethod = AccessTools.Method(teleportType, "Teleport"); + + var beginMethod = AccessTools.Method(teleportType, "Begin", new[] { typeof(List), typeof(Action) }); + if (beginMethod != null) + { + MpCompat.harmony.Patch( + beginMethod, + prefix: new HarmonyMethod(typeof(ModernCC), nameof(PrefixTeleportBegin))); + } + + MP.RegisterSyncMethod(typeof(ModernCC), nameof(SyncedTeleport)); + } + } + + private static bool PrefixBlockInMultiplayer() + { + if (MP.IsInMultiplayer && Current.ProgramState == ProgramState.Playing) + { + Messages.Message("Character Editor cannot be opened during a multiplayer game.", MessageTypeDefOf.RejectInput, false); + return false; + } + return true; + } + + private static bool PrefixBlockWindowContents(Window __instance) + { + if (MP.IsInMultiplayer && Current.ProgramState == ProgramState.Playing) + { + __instance?.Close(false); + Messages.Message("Character Editor cannot be opened during a multiplayer game.", MessageTypeDefOf.RejectInput, false); + return false; + } + return true; + } + + private static bool PrefixTeleportBegin(List pawns, Action onFinish) + { + if (!MP.IsInMultiplayer) + return true; + + if (pawns == null || pawns.Count == 0) + { + onFinish?.Invoke(); + return false; + } + + var map = pawns[0]?.MapHeld; + if (map == null) + { + onFinish?.Invoke(); + return false; + } + + var targetParams = new TargetingParameters + { + canTargetLocations = true, + canTargetPawns = false, + canTargetBuildings = false + }; + + Find.Targeter.BeginTargeting(targetParams, target => + { + IntVec3 cell = target.Cell; + if (!cell.IsValid || !cell.InBounds(map) || !cell.Standable(map)) + { + Messages.Message("MCE_CannotSkipThere".Translate(), MessageTypeDefOf.RejectInput, false); + return; + } + + SyncedTeleport(pawns, cell, map); + }, null, onFinish, null, true); + + return false; + } + + private static void SyncedTeleport(List pawns, IntVec3 cell, Map map) + { + if (pawns == null || pawns.Count == 0 || map == null || teleportMethod == null) + return; + + int num = 0; + for (int i = 0; i < pawns.Count; i++) + { + Pawn p = pawns[i]; + if (p != null && p.Spawned && p.MapHeld == map) + { + IntVec3 targetCell = (i == 0) ? cell : CellFinder.RandomClosewalkCellNear(cell, map, 3, null); + if (!targetCell.IsValid || !targetCell.InBounds(map)) + targetCell = cell; + + if ((bool)teleportMethod.Invoke(null, new object[] { p, targetCell, map })) + { + num++; + } + } + } + + if (num > 0) + { + FleckDef namedSilentFail = DefDatabase.GetNamedSilentFail("PsycastSkipInnerExit"); + if (namedSilentFail != null) + FleckMaker.Static(cell, map, namedSilentFail, 1f); + + Messages.Message( + (num == 1) ? "MCE_TeleportedOne".Translate(pawns[0].LabelShortCap) : "MCE_TeleportedN".Translate(num), + MessageTypeDefOf.TaskCompletion, false); + } + } + } +} diff --git a/Source/Mods/ModernFactionMenu.cs b/Source/Mods/ModernFactionMenu.cs new file mode 100644 index 0000000..896ed98 --- /dev/null +++ b/Source/Mods/ModernFactionMenu.cs @@ -0,0 +1,30 @@ +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// Modern Faction Menu by Astryl + /// + [MpCompatFor("astryl.ModernFactionMenu")] + [MpCompatFor("astryl.modernfactionmenu")] + internal class ModernFactionMenu + { + public ModernFactionMenu(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var windowType = AccessTools.TypeByName("ModernFactionMenu.Window_ModernFactions"); + if (windowType != null) + { + MP.RegisterSyncDialogNodeTree(windowType, "OpenComms"); + } + + var compType = AccessTools.TypeByName("ModernFactionMenu.GameComponent_FactionPins"); + if (compType != null) + { + MP.RegisterSyncMethod(compType, "TogglePin"); + } + } + } +} diff --git a/Source/Mods/ModernHistoryMenu.cs b/Source/Mods/ModernHistoryMenu.cs new file mode 100644 index 0000000..cd86a39 --- /dev/null +++ b/Source/Mods/ModernHistoryMenu.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// Modern History Menu by Astryl + /// + [MpCompatFor("astryl.modernhistorymenu")] + [MpCompatFor("astryl.ModernHistoryMenu")] + internal class ModernHistoryMenu + { + public ModernHistoryMenu(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + // 1. HistoryAutoRecorderWorker_RealPlaytimeHours:PullRecord + var pullRecordMethod = AccessTools.Method("HistoryExpanded.HistoryAutoRecorderWorker_RealPlaytimeHours:PullRecord"); + if (pullRecordMethod != null) + { + MpCompat.harmony.Patch( + pullRecordMethod, + prefix: new HarmonyMethod(typeof(ModernHistoryMenu), nameof(PullRecordPrefix))); + } + + // 2. GameComponent_SkillHistory:SampleAll (transpile RealPlayTimeInteracting to deterministic in-game time) + var sampleAllMethod = AccessTools.Method("HistoryExpanded.GameComponent_SkillHistory:SampleAll"); + if (sampleAllMethod != null) + { + MpCompat.harmony.Patch( + sampleAllMethod, + transpiler: new HarmonyMethod(typeof(ModernHistoryMenu), nameof(SampleAllTranspiler))); + } + + // 3. Sync methods on GameComponent_SkillHistory that modify serialized state + var compType = AccessTools.TypeByName("HistoryExpanded.GameComponent_SkillHistory"); + if (compType != null) + { + MP.RegisterSyncMethod(compType, "TogglePin"); + MP.RegisterSyncMethod(compType, "ToggleFavoriteGraph"); + MP.RegisterSyncMethod(compType, "SetGraphOrder"); + } + } + + private static bool PullRecordPrefix(ref float __result) + { + if (MP.IsInMultiplayer) + { + // Equivalent 1x speed hours (60 ticks/sec * 3600 sec/hr = 216000 ticks/hr) + __result = (float)Find.TickManager.TicksGame / 216000f; + return false; + } + return true; + } + + public static float GetDeterministicRealPlayTime(GameInfo gameInfo) + { + if (MP.IsInMultiplayer) + { + // Return playtime in seconds derived deterministically from game ticks + return (float)Find.TickManager.TicksGame / 60f; + } + return gameInfo.RealPlayTimeInteracting; + } + + private static IEnumerable SampleAllTranspiler(IEnumerable instructions) + { + var targetGetter = AccessTools.PropertyGetter(typeof(GameInfo), nameof(GameInfo.RealPlayTimeInteracting)); + var replacementMethod = AccessTools.Method(typeof(ModernHistoryMenu), nameof(GetDeterministicRealPlayTime)); + + foreach (var instr in instructions) + { + if (instr.Calls(targetGetter)) + { + yield return new CodeInstruction(OpCodes.Call, replacementMethod); + } + else + { + yield return instr; + } + } + } + } +} diff --git a/Source/Mods/ModernQuestMenu.cs b/Source/Mods/ModernQuestMenu.cs new file mode 100644 index 0000000..bc04f9e --- /dev/null +++ b/Source/Mods/ModernQuestMenu.cs @@ -0,0 +1,24 @@ +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// Modern Quest Menu by Astryl + /// + [MpCompatFor("astryl.ModernQuestMenu")] + [MpCompatFor("astryl.modernquestmenu")] + internal class ModernQuestMenu + { + public ModernQuestMenu(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var compType = AccessTools.TypeByName("ModernQuestMenu.ModernQuestMenuGameComp"); + if (compType != null) + { + MP.RegisterSyncMethod(compType, "TogglePin"); + } + } + } +} diff --git a/Source/Mods/ModernSocialTab.cs b/Source/Mods/ModernSocialTab.cs new file mode 100644 index 0000000..8b7be55 --- /dev/null +++ b/Source/Mods/ModernSocialTab.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// Modern Social Tab by Astryl + /// + [MpCompatFor("astryl.modernsocialtab")] + [MpCompatFor("astryl.ModernSocialTab")] + internal class ModernSocialTab + { + private static Type compType; + private static FieldInfo pinnedPawnsField; + + public ModernSocialTab(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var texType = AccessTools.TypeByName("ModernSocialTab.SocialTex"); + if (texType != null) + { + var pinField = AccessTools.Field(texType, "Pin"); + if (pinField != null && pinField.GetValue(null) == null) + { + texType.TypeInitializer?.Invoke(null, null); + } + } + + compType = AccessTools.TypeByName("ModernSocialTab.ModernSocialTabGameComp"); + pinnedPawnsField = AccessTools.Field(compType, "PinnedPawns"); + + MP.RegisterSyncMethod(typeof(ModernSocialTab), nameof(SyncedTogglePin)); + + var drawerType = AccessTools.TypeByName("ModernSocialTab.SocialTabDrawer"); + if (drawerType != null) + { + var drawRosterCardMethod = AccessTools.Method(drawerType, "DrawRosterCard"); + var handleKeyboardMethod = AccessTools.Method(drawerType, "HandleKeyboard"); + + var transpiler = new HarmonyMethod(typeof(ModernSocialTab), nameof(TranspileListAddRemove)); + + if (drawRosterCardMethod != null) + MpCompat.harmony.Patch(drawRosterCardMethod, transpiler: transpiler); + + if (handleKeyboardMethod != null) + MpCompat.harmony.Patch(handleKeyboardMethod, transpiler: transpiler); + } + } + + private static void SyncedTogglePin(Pawn pawn) + { + if (pawn == null || compType == null || pinnedPawnsField == null) + return; + + var comp = Current.Game?.GetComponent(compType); + if (comp == null) + return; + + if (pinnedPawnsField.GetValue(comp) is List list) + { + if (!list.Remove(pawn)) + list.Add(pawn); + } + } + + public static bool SyncedListRemove(List list, Pawn pawn) + { + if (MP.IsInMultiplayer) + { + SyncedTogglePin(pawn); + return false; + } + return list.Remove(pawn); + } + + public static void SyncedListAdd(List list, Pawn pawn) + { + if (MP.IsInMultiplayer) + { + SyncedTogglePin(pawn); + return; + } + list.Add(pawn); + } + + private static IEnumerable TranspileListAddRemove(IEnumerable instructions) + { + var removeMethod = AccessTools.Method(typeof(List), nameof(List.Remove)); + var addMethod = AccessTools.Method(typeof(List), nameof(List.Add)); + + var syncedRemove = AccessTools.Method(typeof(ModernSocialTab), nameof(SyncedListRemove)); + var syncedAdd = AccessTools.Method(typeof(ModernSocialTab), nameof(SyncedListAdd)); + + foreach (var instr in instructions) + { + if (instr.Calls(removeMethod)) + { + yield return new CodeInstruction(OpCodes.Call, syncedRemove); + } + else if (instr.Calls(addMethod)) + { + yield return new CodeInstruction(OpCodes.Call, syncedAdd); + } + else + { + yield return instr; + } + } + } + } +} diff --git a/Source/Mods/NiceBillTab.cs b/Source/Mods/NiceBillTab.cs new file mode 100644 index 0000000..c964e73 --- /dev/null +++ b/Source/Mods/NiceBillTab.cs @@ -0,0 +1,442 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Nice Bill Tab & Nice Bill Tab Expansion by Andromeda / Hicon + /// + /// + [MpCompatFor("andromeda.nicebilltab")] + [MpCompatFor("hicon.nicebilltabexpansion")] + internal class NiceBillTab + { + private static Type tabBillsDrawerType; + private static Type recipeSelectionType; + private static Type settingsType; + private static MethodInfo setMaterialMethod; + private static MethodInfo autoRenameMethod; + private static MethodInfo getDropIndexMethod; + private static MethodInfo selectBillMethod; + private static MethodInfo doAutomaticScrollDecisionMethod; + private static FieldInfo shouldRefreshFilterField; + private static FieldInfo draggedBillIndexField; + private static FieldInfo lastSelTableField; + private static FieldInfo lockedSelectionField; + private static FieldInfo enableAutoNamingField; + private static FieldInfo workTableField; + private static FieldInfo selectedRecipeField; + private static FieldInfo styleField; + + public NiceBillTab(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + tabBillsDrawerType = AccessTools.TypeByName("NiceBillTab.TabBillsDrawer"); + recipeSelectionType = AccessTools.TypeByName("NiceBillTab.RecipeSelection"); + settingsType = AccessTools.TypeByName("NiceBillTab.Settings"); + + if (tabBillsDrawerType != null && recipeSelectionType != null) + { + // 1. Sync quantity adjustments (with modifier support) and suspend adjustments + var minusActionMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "MinusAction", new[] { typeof(Bill_Production), typeof(RecipeDef) }); + var plusActionMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "PlusAction", new[] { typeof(Bill_Production), typeof(RecipeDef) }); + if (minusActionMethod != null) + { + MpCompat.harmony.Patch(minusActionMethod, prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(MinusAction_Prefix))); + } + if (plusActionMethod != null) + { + MpCompat.harmony.Patch(plusActionMethod, prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(PlusAction_Prefix))); + } + + MP.RegisterSyncMethod(typeof(NiceBillTab), nameof(SyncedMinusAction)).CancelIfAnyArgNull(); + MP.RegisterSyncMethod(typeof(NiceBillTab), nameof(SyncedPlusAction)).CancelIfAnyArgNull(); + MP.RegisterSyncMethod(tabBillsDrawerType, "SuspendBill").CancelIfAnyArgNull(); + + var insertBillMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "InsertBill", new[] { typeof(Building_WorkTable), typeof(Bill), typeof(int) }); + if (insertBillMethod != null) + { + MpCompat.harmony.Patch(insertBillMethod, prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(InsertBill_Prefix))); + MP.RegisterSyncMethod(insertBillMethod).ExposeParameter(1); + } + + // Reflection members for bill drawer + setMaterialMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "SetMaterialToBill", new[] { recipeSelectionType, typeof(RecipeDef), typeof(Bill) }); + autoRenameMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "AutoRenameBill", new[] { typeof(Bill) }); + getDropIndexMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "GetDropIndex", new[] { typeof(List), typeof(Vector2), typeof(float) }); + selectBillMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "SelectBill", new[] { typeof(Bill), typeof(bool) }); + doAutomaticScrollDecisionMethod = AccessTools.DeclaredMethod(tabBillsDrawerType, "DoAutomaticScrollDecision", new[] { typeof(bool) }); + + shouldRefreshFilterField = AccessTools.DeclaredField(tabBillsDrawerType, "shouldRefreshFilter"); + draggedBillIndexField = AccessTools.DeclaredField(tabBillsDrawerType, "draggedBillIndex"); + lastSelTableField = AccessTools.DeclaredField(tabBillsDrawerType, "LastSelTable"); + lockedSelectionField = AccessTools.DeclaredField(tabBillsDrawerType, "LockedSelection"); + + // Reflection fields for recipe selection + workTableField = AccessTools.DeclaredField(recipeSelectionType, "workTable"); + selectedRecipeField = AccessTools.DeclaredField(recipeSelectionType, "SelectedRecipe"); + styleField = AccessTools.DeclaredField(recipeSelectionType, "style"); + + if (settingsType != null) + { + enableAutoNamingField = AccessTools.DeclaredField(settingsType, "EnableAutoNaming"); + } + + // 2. Pre-assign material & name before AddBill so MP serialization captures full bill state + var tryAddBill = AccessTools.DeclaredMethod(tabBillsDrawerType, "TryAddBillToQueue", new[] { recipeSelectionType }); + if (tryAddBill != null) + { + MpCompat.harmony.Patch( + tryAddBill, + prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(TryAddBillToQueue_Prefix)) + ); + } + + // 3. Sync bill drag-and-drop reordering + var handleBillDrop = AccessTools.DeclaredMethod(tabBillsDrawerType, "HandleBillDrop", new[] { typeof(List), typeof(Vector2), typeof(float) }); + if (handleBillDrop != null) + { + MpCompat.harmony.Patch( + handleBillDrop, + prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(HandleBillDrop_Prefix)) + ); + } + + // Register our synced bill reorder method + MP.RegisterSyncMethod(typeof(NiceBillTab), nameof(SyncedReorderBill)).CancelIfAnyArgNull(); + } + else + { + Log.Warning("[Multiplayer Compat] NiceBillTab: Could not find NiceBillTab.TabBillsDrawer"); + } + + // 4. Also patch Utils.TryAddBillToQueue + var utilsType = AccessTools.TypeByName("NiceBillTab.Utils"); + if (utilsType != null) + { + var utilsTryAddBill = AccessTools.DeclaredMethod(utilsType, "TryAddBillToQueue", new[] { typeof(ThingWithComps), typeof(RecipeDef), typeof(int), typeof(Map) }); + if (utilsTryAddBill != null) + { + MpCompat.harmony.Patch( + utilsTryAddBill, + prefix: new HarmonyMethod(typeof(NiceBillTab), nameof(Utils_TryAddBillToQueue_Prefix)) + ); + } + } + + // 5. Sync NiceBillTabExpansion hidden recipes + var hiddenRecipeStoreType = AccessTools.TypeByName("NiceBillTabExpansion.Storage.HiddenRecipeStore"); + if (hiddenRecipeStoreType != null) + { + MP.RegisterSyncMethod(hiddenRecipeStoreType, "SetHidden").CancelIfAnyArgNull(); + } + } + + private static bool TryAddBillToQueue_Prefix(object selection, ref bool __result) + { + if (!MP.IsInMultiplayer || selection == null) return true; + + var workTable = workTableField?.GetValue(selection) as Building_WorkTable; + var recipe = selectedRecipeField?.GetValue(selection) as RecipeDef; + var style = styleField?.GetValue(selection) as Precept_ThingStyle; + + if (workTable == null || !workTable.Spawned || recipe == null) + { + __result = false; + return false; + } + + var lastSelTable = lastSelTableField?.GetValue(null) as Building_WorkTable; + var map = workTable.Map ?? lastSelTable?.Map; + + if (lockedSelectionField?.GetValue(null) == selection) + { + lockedSelectionField.SetValue(null, null); + } + + if (ModsConfig.BiotechActive && recipe.mechanitorOnlyRecipe && (map == null || !GenCollection.Any(map.mapPawns.FreeColonists, MechanitorUtility.IsMechanitor))) + { + Find.WindowStack.Add(new Dialog_MessageBox("RecipeRequiresMechanitor".Translate(recipe.LabelCap))); + __result = false; + return false; + } + + if (map != null && !GenCollection.Any(map.mapPawns.FreeColonists, col => recipe.PawnSatisfiesSkillRequirements(col))) + { + Bill.CreateNoPawnsWithSkillDialog(recipe); + } + + shouldRefreshFilterField?.SetValue(null, true); + + Bill bill = BillUtility.MakeNewBill(recipe, style); + + int multiplier = GetCurrentMultiplier(); + if (multiplier > 1 && bill is Bill_Production billProd) + { + billProd.repeatMode = BillRepeatModeDefOf.RepeatCount; + billProd.repeatCount = multiplier; + } + + // Pre-apply material and custom auto-naming BEFORE AddBill so MP's ExposeParameter serializes it properly + try + { + setMaterialMethod?.Invoke(null, new[] { selection, recipe, bill }); + if (enableAutoNamingField == null || (bool)enableAutoNamingField.GetValue(null)) + { + autoRenameMethod?.Invoke(null, new object[] { bill }); + } + } + catch (Exception e) + { + Log.Warning($"[Multiplayer Compat] NiceBillTab: Error initializing bill before AddBill: {e}"); + } + + // Call AddBill (which is synchronized by Multiplayer core) + workTable.billStack.AddBill(bill); + + if (recipe.conceptLearned != null) + { + PlayerKnowledgeDatabase.KnowledgeDemonstrated(recipe.conceptLearned, KnowledgeAmount.Total); + } + + if (TutorSystem.TutorialMode) + { + TutorSystem.Notify_Event("AddBill-" + recipe.LabelCap.Resolve()); + } + + if (workTable != lastSelTable) + { + CameraJumper.TryJumpAndSelect(workTable); + doAutomaticScrollDecisionMethod?.Invoke(null, new object[] { true }); + } + else + { + selectBillMethod?.Invoke(null, new object[] { bill, false }); + doAutomaticScrollDecisionMethod?.Invoke(null, new object[] { false }); + } + + __result = true; + return false; + } + + private static bool Utils_TryAddBillToQueue_Prefix(ThingWithComps workbench, RecipeDef recipe, int count, Map map) + { + if (!MP.IsInMultiplayer || !(workbench is Building_WorkTable workTable) || recipe == null) return true; + + if (ModsConfig.BiotechActive && recipe.mechanitorOnlyRecipe && (map == null || !GenCollection.Any(map.mapPawns.FreeColonists, MechanitorUtility.IsMechanitor))) + { + Find.WindowStack.Add(new Dialog_MessageBox("RecipeRequiresMechanitor".Translate(recipe.LabelCap))); + return false; + } + + if (map != null && !GenCollection.Any(map.mapPawns.FreeColonists, col => recipe.PawnSatisfiesSkillRequirements(col))) + { + Bill.CreateNoPawnsWithSkillDialog(recipe); + return false; + } + + Bill bill = BillUtility.MakeNewBill(recipe, null); + int multiplier = GetCurrentMultiplier(); + int totalCount = count * (multiplier > 1 ? multiplier : 1); + if (bill is Bill_Production billProd && recipe.products != null && recipe.products.Count > 0) + { + int prodCount = recipe.products[0].count; + int finalCount = Mathf.CeilToInt((float)totalCount / (float)prodCount); + billProd.repeatMode = BillRepeatModeDefOf.RepeatCount; + billProd.repeatCount = finalCount; + } + else if (multiplier > 1 && bill is Bill_Production billProd2) + { + billProd2.repeatMode = BillRepeatModeDefOf.RepeatCount; + billProd2.repeatCount = multiplier; + } + + var lastSelTable = lastSelTableField?.GetValue(null) as Building_WorkTable; + if (workbench == lastSelTable) + { + shouldRefreshFilterField?.SetValue(null, true); + doAutomaticScrollDecisionMethod?.Invoke(null, new object[] { true }); + } + + workTable.billStack.AddBill(bill); + + if (recipe.conceptLearned != null) + { + PlayerKnowledgeDatabase.KnowledgeDemonstrated(recipe.conceptLearned, KnowledgeAmount.Total); + } + + return false; + } + + private static bool HandleBillDrop_Prefix(List filteredBills, Vector2 mousePosition, float width) + { + if (!MP.IsInMultiplayer) return true; + + int draggedIndex = (int)(draggedBillIndexField?.GetValue(null) ?? -1); + if (draggedIndex >= 0 && filteredBills != null && draggedIndex < filteredBills.Count) + { + int dropIndex = -1; + try + { + dropIndex = (int)(getDropIndexMethod?.Invoke(null, new object[] { filteredBills, mousePosition, width }) ?? -1); + } + catch + { + dropIndex = -1; + } + + if (dropIndex >= 0 && dropIndex != draggedIndex) + { + Bill item = filteredBills[draggedIndex]; + var table = lastSelTableField?.GetValue(null) as Building_WorkTable; + if (table?.billStack != null && item != null) + { + SyncedReorderBill(table, item, dropIndex); + } + } + } + + return false; + } + + private static void SyncedReorderBill(Building_WorkTable table, Bill bill, int targetIndex) + { + if (table?.billStack?.Bills == null || bill == null) return; + + int currentIndex = table.billStack.Bills.IndexOf(bill); + if (currentIndex >= 0 && targetIndex >= 0) + { + table.billStack.Bills.RemoveAt(currentIndex); + if (targetIndex > table.billStack.Bills.Count) + { + targetIndex = table.billStack.Bills.Count; + } + table.billStack.Bills.Insert(targetIndex, bill); + shouldRefreshFilterField?.SetValue(null, true); + } + } + + private static int GetCurrentMultiplier() + { + try + { + if (KeyBindingDefOf.ModifierIncrement_100x != null && KeyBindingDefOf.ModifierIncrement_100x.IsDown) + return 100; + if (KeyBindingDefOf.ModifierIncrement_10x != null && KeyBindingDefOf.ModifierIncrement_10x.IsDown) + return 10; + } + catch + { + // ignored + } + + if (Event.current != null) + { + if (Event.current.control) + return 100; + if (Event.current.shift) + return 10; + } + + if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) + return 100; + if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) + return 10; + + return 1; + } + + private static bool MinusAction_Prefix(Bill_Production billProd, RecipeDef recipe) + { + if (!MP.IsInMultiplayer) return true; + SyncedMinusAction(billProd, recipe, GetCurrentMultiplier()); + return false; + } + + private static bool PlusAction_Prefix(Bill_Production billProd, RecipeDef recipe) + { + if (!MP.IsInMultiplayer) return true; + SyncedPlusAction(billProd, recipe, GetCurrentMultiplier()); + return false; + } + + [SyncMethod] + private static void SyncedMinusAction(Bill_Production billProd, RecipeDef recipe, int multiplier) + { + if (billProd == null || recipe == null) return; + + if (billProd.repeatMode == BillRepeatModeDefOf.Forever) + { + billProd.repeatMode = BillRepeatModeDefOf.RepeatCount; + billProd.repeatCount = 1; + } + else if (billProd.repeatMode == BillRepeatModeDefOf.TargetCount) + { + int num = recipe.targetCountAdjustment * multiplier; + billProd.targetCount = Mathf.Max(0, billProd.targetCount - num); + billProd.unpauseWhenYouHave = Mathf.Max(0, billProd.unpauseWhenYouHave - num); + } + else if (billProd.repeatMode == BillRepeatModeDefOf.RepeatCount) + { + billProd.repeatCount = Mathf.Max(0, billProd.repeatCount - multiplier); + } + else if (billProd.repeatMode?.defName == "TD_PersonCount" || billProd.repeatMode?.defName == "TD_XPerPerson") + { + int num2 = recipe.targetCountAdjustment * multiplier; + if (billProd.repeatMode.defName == "TD_XPerPerson") + { + billProd.targetCount = Mathf.Max(0, billProd.targetCount - num2); + billProd.unpauseWhenYouHave = Mathf.Max(0, billProd.unpauseWhenYouHave - num2); + } + else + { + billProd.targetCount -= num2; + billProd.unpauseWhenYouHave -= num2; + } + } + } + + [SyncMethod] + private static void SyncedPlusAction(Bill_Production billProd, RecipeDef recipe, int multiplier) + { + if (billProd == null || recipe == null) return; + + if (billProd.repeatMode == BillRepeatModeDefOf.Forever) + { + billProd.repeatMode = BillRepeatModeDefOf.RepeatCount; + billProd.repeatCount = 1; + } + else if (billProd.repeatMode == BillRepeatModeDefOf.TargetCount) + { + int num = recipe.targetCountAdjustment * multiplier; + billProd.targetCount += num; + billProd.unpauseWhenYouHave += num; + } + else if (billProd.repeatMode == BillRepeatModeDefOf.RepeatCount) + { + billProd.repeatCount += multiplier; + } + else if (billProd.repeatMode?.defName == "TD_PersonCount" || billProd.repeatMode?.defName == "TD_XPerPerson") + { + int num2 = recipe.targetCountAdjustment * multiplier; + billProd.targetCount += num2; + billProd.unpauseWhenYouHave += num2; + } + } + + private static void InsertBill_Prefix(Building_WorkTable SelTable, Bill bill, int index) + { + if (MP.IsExecutingSyncCommand && bill != null && bill.loadID < 0) + { + bill.loadID = Find.UniqueIDsManager.GetNextBillID(); + } + } + } +} diff --git a/Source/Mods/RadiusUIColonistBar.cs b/Source/Mods/RadiusUIColonistBar.cs new file mode 100644 index 0000000..6977714 --- /dev/null +++ b/Source/Mods/RadiusUIColonistBar.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Radius UI - Colonist Bar by Astryl + /// + [MpCompatFor("astryl.radiusui.colonistbar")] + internal class RadiusUIColonistBar + { + private static readonly Dictionary graphicCache = new(); + + private static bool patched; + + public RadiusUIColonistBar(ModContentPack mod) + { + TryPatch(); + LongEventHandler.ExecuteWhenFinished(TryPatch); + } + + private static void TryPatch() + { + if (patched) + return; + + var graphicForMethod = AccessTools.Method("RadiusColonistBar.KitPreview:GraphicFor"); + if (graphicForMethod != null) + { + MpCompat.harmony.Patch( + graphicForMethod, + prefix: new HarmonyMethod(typeof(RadiusUIColonistBar), nameof(PrefixGraphicFor))); + patched = true; + } + } + + private static bool PrefixGraphicFor(ThingDef def, BodyTypeDef bt, ref Graphic __result) + { + if (def == null || bt == null) + { + __result = null; + return false; + } + + string key = def.defName + "|" + bt.defName; + if (graphicCache.TryGetValue(key, out var cached)) + { + __result = cached; + return false; + } + + Graphic graphic = null; + try + { + ThingDef stuff = def.MadeFromStuff ? GenStuff.DefaultStuffFor(def) : null; + var apparel = (Apparel)Activator.CreateInstance(def.thingClass); + apparel.def = def; + apparel.SetStuffDirect(stuff); + + if (ApparelGraphicRecordGetter.TryGetGraphicApparel(apparel, bt, false, out var rec)) + { + graphic = rec.graphic; + } + } + catch + { + } + + graphicCache[key] = graphic; + __result = graphic; + return false; + } + } +} diff --git a/Source/Mods/RadiusUIFactionMenu.cs b/Source/Mods/RadiusUIFactionMenu.cs new file mode 100644 index 0000000..12ff08c --- /dev/null +++ b/Source/Mods/RadiusUIFactionMenu.cs @@ -0,0 +1,32 @@ +using System; +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// Radius UI - Faction Menu by Astryl + /// + [MpCompatFor("astryl.RadiusUI.FactionMenu")] + internal class RadiusUIFactionMenu + { + public RadiusUIFactionMenu(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + // Register DialogNodeTree sync for comms console opened from Faction Menu + var uiType = AccessTools.TypeByName("RadiusUI.FactionMenu.UI.Ui"); + if (uiType != null) + { + MP.RegisterSyncDialogNodeTree(uiType, "OpenComms"); + } + + // Register synced closing and pause locking for CommsDialog + var commsDialogType = AccessTools.TypeByName("RadiusUI.FactionMenu.UI.Dialogs.CommsDialog"); + if (commsDialogType != null) + { + DialogUtilities.RegisterDialogCloseSync(commsDialogType, addPauseLock: true); + } + } + } +} diff --git a/Source/Mods/RadiusUIHealthTab.cs b/Source/Mods/RadiusUIHealthTab.cs new file mode 100644 index 0000000..1144add --- /dev/null +++ b/Source/Mods/RadiusUIHealthTab.cs @@ -0,0 +1,143 @@ +using System; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Radius UI - Health Tab by Astryl + /// + [MpCompatFor("astryl.RadiusUI.HealthTab")] + internal class RadiusUIHealthTab + { + private static ISyncField syncMedCare; + private static ISyncField syncSelfTend; + + public RadiusUIHealthTab(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var syncFieldsType = AccessTools.TypeByName("Multiplayer.Client.SyncFields"); + syncMedCare = (ISyncField)AccessTools.Field(syncFieldsType, "SyncMedCare")?.GetValue(null); + syncSelfTend = (ISyncField)AccessTools.Field(syncFieldsType, "SyncSelfTend")?.GetValue(null); + + // TriagePanel: Prevent background local doctor assignments from desyncing + var triagePanelType = AccessTools.TypeByName("RadiusUI.HealthTab.TriagePanel"); + MpCompat.harmony.Patch( + AccessTools.Method(triagePanelType, "Enforce"), + prefix: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(PrefixEnforce))); + + // TriagePanel: Sync player-directed jobs + MP.RegisterSyncMethod(triagePanelType, "IssueTend"); + MP.RegisterSyncMethod(triagePanelType, "IssueRescue"); + + // GameComp_TendRules: Sync care rules and preferred medicine + var tendRulesType = AccessTools.TypeByName("RadiusUI.HealthTab.GameComp_TendRules"); + if (tendRulesType != null) + { + MP.RegisterSyncMethod(tendRulesType, "Set"); + MP.RegisterSyncMethod(tendRulesType, "SetPreferredMed"); + } + + // HealthTabDrawer: Watch medCare & selfTend during inspector / overview drawing + var drawerType = AccessTools.TypeByName("RadiusUI.HealthTab.HealthTabDrawer"); + MpCompat.harmony.Patch( + AccessTools.Method(drawerType, "Draw", new[] { typeof(Rect), typeof(Pawn), typeof(Thing) }), + prefix: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(PreDrawHealthTab)), + postfix: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(PostDrawHealthTab)), + finalizer: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(FinalizeDrawHealthTab))); + + // ModernDropdown: Watch medCare & selfTend during dropdown click executions + var modernDropdownType = AccessTools.TypeByName("RadiusUI.HealthTab.ModernDropdown"); + MpCompat.harmony.Patch( + AccessTools.Method(modernDropdownType, nameof(Window.DoWindowContents)), + prefix: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(PreDoDropdownContents)), + postfix: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(PostDoDropdownContents)), + finalizer: new HarmonyMethod(typeof(RadiusUIHealthTab), nameof(FinalizeDoDropdownContents))); + } + + private static bool PrefixEnforce() + { + // In MP, Enforce() is driven by client-local static s_manual pins and Find.CurrentMap. + // Disabling Enforce in multiplayer prevents background autonomous doctor reassignment desyncs, + // while manual "Tend now" and "Rescue to bed" clicks remain fully synced. + return !MP.IsInMultiplayer; + } + + private static void PreDrawHealthTab(Pawn pawn, out bool __state) + { + __state = false; + // Enemy pawns, wild animals, and non-colony entities do not have playerSettings. + // Watching medCare/selfTend on them throws a NullReferenceException in MpReflection getter. + if (!MP.IsInMultiplayer || pawn?.playerSettings == null) + return; + + MP.WatchBegin(); + __state = true; + syncMedCare?.Watch(pawn); + syncSelfTend?.Watch(pawn); + } + + private static void PostDrawHealthTab(ref bool __state) + { + if (__state) + { + __state = false; + MP.WatchEnd(); + } + } + + private static Exception FinalizeDrawHealthTab(Exception __exception, ref bool __state) + { + if (__state) + { + __state = false; + MP.WatchEnd(); + } + return __exception; + } + + private static void PreDoDropdownContents(out bool __state) + { + __state = false; + if (!MP.IsInMultiplayer) + return; + + MP.WatchBegin(); + __state = true; + var pawns = PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive_OfPlayerFaction; + if (pawns != null) + { + foreach (var p in pawns) + { + if (p?.playerSettings != null) + { + syncMedCare?.Watch(p); + syncSelfTend?.Watch(p); + } + } + } + } + + private static void PostDoDropdownContents(ref bool __state) + { + if (__state) + { + __state = false; + MP.WatchEnd(); + } + } + + private static Exception FinalizeDoDropdownContents(Exception __exception, ref bool __state) + { + if (__state) + { + __state = false; + MP.WatchEnd(); + } + return __exception; + } + } +} diff --git a/Source/Mods/RadiusUIInspector.cs b/Source/Mods/RadiusUIInspector.cs new file mode 100644 index 0000000..521a93c --- /dev/null +++ b/Source/Mods/RadiusUIInspector.cs @@ -0,0 +1,80 @@ +using System; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Radius UI - Inspector by Astryl + /// + [MpCompatFor("astryl.RadiusUI.Inspector")] + internal class RadiusUIInspector + { + private static ISyncField syncMedCare; + private static ISyncField syncSelfTend; + private static ISyncField syncHostilityResponse; + + public RadiusUIInspector(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var syncFieldsType = AccessTools.TypeByName("Multiplayer.Client.SyncFields"); + syncMedCare = (ISyncField)AccessTools.Field(syncFieldsType, "SyncMedCare")?.GetValue(null); + syncSelfTend = (ISyncField)AccessTools.Field(syncFieldsType, "SyncSelfTend")?.GetValue(null); + syncHostilityResponse = (ISyncField)AccessTools.Field(syncFieldsType, "SyncHostilityResponse")?.GetValue(null); + + // StoragePanel: Provide ThingFilterContext so Multiplayer synchronizes storage filter settings + var storagePanelType = AccessTools.TypeByName("RadiusUIInspector.StoragePanel"); + MpCompat.harmony.Patch( + AccessTools.Method(storagePanelType, "Draw", new[] { typeof(Rect), typeof(IStoreSettingsParent) }), + prefix: new HarmonyMethod(typeof(RadiusUIInspector), nameof(PreDrawStoragePanel)), + postfix: new HarmonyMethod(typeof(RadiusUIInspector), nameof(PostDrawStoragePanel))); + + // ControlsPanel: Watch medical care, self-tend, and hostility response + var controlsPanelType = AccessTools.TypeByName("RadiusUIInspector.ControlsPanel"); + MpCompat.harmony.Patch( + AccessTools.Method(controlsPanelType, "DrawMedical", new[] { typeof(Pawn), typeof(float), typeof(float), typeof(float) }), + prefix: new HarmonyMethod(typeof(RadiusUIInspector), nameof(PreDrawMedical)), + postfix: new HarmonyMethod(typeof(RadiusUIInspector), nameof(PostDrawMedical))); + } + + private static void PreDrawStoragePanel(IStoreSettingsParent parent) + { + if (!MP.IsInMultiplayer || parent == null) + return; + + MP.SetThingFilterContext(new StorageFilterWrapper(parent)); + } + + private static void PostDrawStoragePanel() + { + if (MP.IsInMultiplayer) + MP.SetThingFilterContext(null); + } + + private static void PreDrawMedical(Pawn pawn) + { + if (!MP.IsInMultiplayer || pawn == null) + return; + + MP.WatchBegin(); + syncMedCare?.Watch(pawn); + syncSelfTend?.Watch(pawn); + syncHostilityResponse?.Watch(pawn); + } + + private static void PostDrawMedical() + { + if (MP.IsInMultiplayer) + MP.WatchEnd(); + } + + private record StorageFilterWrapper(IStoreSettingsParent Parent) : ThingFilterContext + { + public override ThingFilter Filter => Parent?.GetStoreSettings()?.filter; + public override ThingFilter ParentFilter => Parent?.GetParentStoreSettings()?.filter; + } + } +} diff --git a/Source/Mods/RadiusUIMissionControl.cs b/Source/Mods/RadiusUIMissionControl.cs new file mode 100644 index 0000000..6cd9211 --- /dev/null +++ b/Source/Mods/RadiusUIMissionControl.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Compat +{ + /// Radius UI - Mission Control by Astryl + /// + [MpCompatFor("astryl.RadiusUI.MissionControl")] + internal class RadiusUIMissionControl + { + private static bool inGlobalControlsDraw = false; + private static Type timeVoteType; + private static MethodInfo sendTimeVoteMethod; + private static MethodInfo togglePausedMethod; + private static PropertyInfo curTimeSpeedUIProp; + + public RadiusUIMissionControl(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var timeControlPatchType = AccessTools.TypeByName("Multiplayer.Client.AsyncTime.TimeControlPatch"); + timeVoteType = AccessTools.TypeByName("Multiplayer.Common.TimeVote"); + + if (timeControlPatchType != null && timeVoteType != null) + { + sendTimeVoteMethod = AccessTools.Method(timeControlPatchType, "SendTimeVote", new[] { timeVoteType }); + togglePausedMethod = AccessTools.Method(timeControlPatchType, "TogglePaused", new[] { typeof(Nullable<>).MakeGenericType(timeVoteType) }); + curTimeSpeedUIProp = AccessTools.Property(timeControlPatchType, "CurTimeSpeedUI"); + } + + // Track when Mission Control's GlobalControlsRenderer is drawing + var rendererType = AccessTools.TypeByName("RadiusUI.MissionControl.GlobalControlsRenderer"); + var drawMethod = AccessTools.Method(rendererType, "Draw"); + if (drawMethod != null) + { + MpCompat.harmony.Patch( + drawMethod, + prefix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PreGlobalControlsDraw)), + postfix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PostGlobalControlsDraw)), + finalizer: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PostGlobalControlsDraw))); + } + + // Intercept time speed mutations during GlobalControlsRenderer.Draw and route them to MP time votes + var setCurTimeSpeedMethod = AccessTools.PropertySetter(typeof(TickManager), nameof(TickManager.CurTimeSpeed)); + MpCompat.harmony.Patch( + setCurTimeSpeedMethod, + prefix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PrefixSetCurTimeSpeed))); + + var togglePausedMethodRef = AccessTools.Method(typeof(TickManager), nameof(TickManager.TogglePaused)); + MpCompat.harmony.Patch( + togglePausedMethodRef, + prefix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PrefixTogglePaused))); + + // Provide MP's CurTimeSpeedUI to Mission Control when it queries TickManager + var getCurTimeSpeedMethod = AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.CurTimeSpeed)); + MpCompat.harmony.Patch( + getCurTimeSpeedMethod, + prefix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PrefixGetCurTimeSpeed))); + + var getPausedMethod = AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.Paused)); + MpCompat.harmony.Patch( + getPausedMethod, + prefix: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(PrefixGetPaused))); + + // Transpile OccasionsTracker.CheckReminders to make reminder evaluation deterministic in MP + var occasionsTrackerType = AccessTools.TypeByName("RadiusUI.MissionControl.OccasionsTracker"); + var checkRemindersMethod = AccessTools.Method(occasionsTrackerType, "CheckReminders"); + if (checkRemindersMethod != null) + { + MpCompat.harmony.Patch( + checkRemindersMethod, + transpiler: new HarmonyMethod(typeof(RadiusUIMissionControl), nameof(TranspileCheckReminders))); + } + } + + private static void PreGlobalControlsDraw() => inGlobalControlsDraw = true; + private static void PostGlobalControlsDraw() => inGlobalControlsDraw = false; + + private static int GetCurrentTimeVoteInt() + { + if (curTimeSpeedUIProp != null) + { + try + { + var cur = curTimeSpeedUIProp.GetValue(null); + if (cur != null) + return Convert.ToInt32(cur); + } + catch + { + // Ignore reflection/unboxing failures + } + } + + return (int)(Find.TickManager?.curTimeSpeed ?? TimeSpeed.Normal); + } + + private static bool PrefixGetCurTimeSpeed(ref TimeSpeed __result) + { + if (MP.IsInMultiplayer && inGlobalControlsDraw) + { + try + { + __result = (TimeSpeed)GetCurrentTimeVoteInt(); + return false; + } + catch + { + return true; // Let vanilla handle it if state is invalid + } + } + return true; + } + + private static bool PrefixGetPaused(ref bool __result) + { + if (MP.IsInMultiplayer && inGlobalControlsDraw) + { + try + { + __result = GetCurrentTimeVoteInt() == 0; // TimeSpeed.Paused == 0 + return false; + } + catch + { + return true; + } + } + return true; + } + + private static bool PrefixSetCurTimeSpeed(TimeSpeed value) + { + if (MP.IsInMultiplayer && inGlobalControlsDraw) + { + // Never mutate time speed on passive GUI render passes + if (Event.current != null && (Event.current.type == EventType.Repaint || Event.current.type == EventType.Layout)) + return false; + + int requestedSpeed = (int)value; + if (requestedSpeed == GetCurrentTimeVoteInt()) + return false; // Speed hasn't changed; suppress duplicate votes and sound spam + + SendVote(requestedSpeed); + return false; + } + return true; + } + + private static bool PrefixTogglePaused() + { + if (MP.IsInMultiplayer && inGlobalControlsDraw) + { + if (Event.current != null && (Event.current.type == EventType.Repaint || Event.current.type == EventType.Layout)) + return false; + + SendPauseToggle(); + return false; + } + return true; + } + + private static void SendVote(int speedInt) + { + if (sendTimeVoteMethod != null && timeVoteType != null) + { + var voteObj = Enum.ToObject(timeVoteType, speedInt); + sendTimeVoteMethod.Invoke(null, new[] { voteObj }); + } + } + + private static void SendPauseToggle() + { + if (sendTimeVoteMethod != null && togglePausedMethod != null && curTimeSpeedUIProp != null && timeVoteType != null) + { + var curSpeed = curTimeSpeedUIProp.GetValue(null); + var newVote = togglePausedMethod.Invoke(null, new[] { curSpeed }); + sendTimeVoteMethod.Invoke(null, new[] { newVote }); + } + } + + public static float GetDeterministicReminderTime() + { + if (MP.IsInMultiplayer) + { + return (Find.TickManager?.TicksGame ?? 0) / 60f; + } + return Time.realtimeSinceStartup; + } + + private static IEnumerable TranspileCheckReminders(IEnumerable instructions) + { + var targetMethod = AccessTools.PropertyGetter(typeof(Time), nameof(Time.realtimeSinceStartup)); + var replacementMethod = AccessTools.Method(typeof(RadiusUIMissionControl), nameof(GetDeterministicReminderTime)); + + foreach (var inst in instructions) + { + if (inst.opcode == OpCodes.Call && inst.operand is MethodInfo m && m == targetMethod) + { + inst.operand = replacementMethod; + } + yield return inst; + } + } + } +} \ No newline at end of file diff --git a/Source/Mods/RadiusUIQuestMenu.cs b/Source/Mods/RadiusUIQuestMenu.cs new file mode 100644 index 0000000..a55c701 --- /dev/null +++ b/Source/Mods/RadiusUIQuestMenu.cs @@ -0,0 +1,98 @@ +using System; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Compat +{ + /// Radius UI - Quest Menu by Astryl + /// + [MpCompatFor("astryl.RadiusUI.QuestMenu")] + internal class RadiusUIQuestMenu + { + private static ISyncField syncQuestDismissed; + private static FastInvokeHandler chooseRewardMethod; + private static MethodInfo chosenRewardIndexMethod; + private static MethodInfo choicePartMethod; + private static MethodInfo modelSetDirtyMethod; + private static FieldInfo modelField; + + public RadiusUIQuestMenu(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + var syncFieldsType = AccessTools.TypeByName("Multiplayer.Client.SyncFields"); + syncQuestDismissed = (ISyncField)AccessTools.Field(syncFieldsType, "SyncQuestDismissed")?.GetValue(null); + + var patchQuestChoicesType = AccessTools.TypeByName("Multiplayer.Client.PatchQuestChoices"); + var chooseMethodInfo = AccessTools.Method(patchQuestChoicesType, "Choose"); + if (chooseMethodInfo != null) + chooseRewardMethod = MethodInvoker.GetHandler(chooseMethodInfo); + + var questWindowType = AccessTools.TypeByName("RadiusUIQuestMenu.RadiusQuestWindow"); + var questModelType = AccessTools.TypeByName("RadiusUIQuestMenu.QuestModel"); + + chosenRewardIndexMethod = AccessTools.Method(questWindowType, "ChosenRewardIndex"); + choicePartMethod = AccessTools.Method(questModelType, "ChoicePart"); + modelField = AccessTools.Field(questWindowType, "Model"); + modelSetDirtyMethod = AccessTools.Method(questModelType, "SetDirty"); + + // Intercept DoAccept to ensure reward choices are synced via Multiplayer before accepting + MpCompat.harmony.Patch( + AccessTools.Method(questWindowType, "DoAccept", new[] { typeof(Quest), typeof(Pawn) }), + prefix: new HarmonyMethod(typeof(RadiusUIQuestMenu), nameof(PrefixDoAccept))); + + // Watch quest dismissed toggle + MpCompat.harmony.Patch( + AccessTools.Method(questWindowType, "ToggleSetAside", new[] { typeof(Quest) }), + prefix: new HarmonyMethod(typeof(RadiusUIQuestMenu), nameof(PrefixToggleSetAside))); + } + + private static bool PrefixDoAccept(object __instance, Quest q, Pawn by) + { + if (!MP.IsInMultiplayer) + return true; + + if (choicePartMethod != null && chosenRewardIndexMethod != null && chooseRewardMethod != null) + { + var choicePart = choicePartMethod.Invoke(null, new object[] { q }) as QuestPart_Choice; + if (choicePart != null && choicePart.choices.Count >= 2) + { + int chosenIndex = (int)chosenRewardIndexMethod.Invoke(__instance, new object[] { q }); + if (chosenIndex < 0) + return false; + + // Sync choice selection through Multiplayer's registered sync method + chooseRewardMethod(null, choicePart, chosenIndex); + } + } + + SoundStarter.PlayOneShotOnCamera(SoundDefOf.Quest_Accepted, null); + // Quest.Accept is already a registered sync method in Multiplayer + q.Accept(by); + + syncQuestDismissed?.Watch(q); + q.dismissed = false; + + if (modelField != null && modelSetDirtyMethod != null) + { + var model = modelField.GetValue(__instance); + if (model != null) + modelSetDirtyMethod.Invoke(model, null); + } + + return false; + } + + private static void PrefixToggleSetAside(Quest q) + { + if (MP.IsInMultiplayer && q != null) + { + syncQuestDismissed?.Watch(q); + } + } + } +} diff --git a/Source/Mods/TrueRPGInventory.cs b/Source/Mods/TrueRPGInventory.cs new file mode 100644 index 0000000..e360b63 --- /dev/null +++ b/Source/Mods/TrueRPGInventory.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.API; +using Verse; + +namespace Multiplayer.Compat +{ + /// True RPG Inventory by Astryl + /// + [MpCompatFor("astryl.truerpginventory")] + internal class TrueRPGInventory + { + private static Action markGearDirtyAction; + private static Type gridStateCompType; + private static AccessTools.FieldRef> positionsGetter; + private static AccessTools.FieldRef> shownWeaponsGetter; + private static AccessTools.FieldRef> hiddenHelmetsGetter; + private static AccessTools.FieldRef> hiddenEyewearGetter; + + public TrueRPGInventory(ModContentPack mod) => LongEventHandler.ExecuteWhenFinished(LatePatch); + + private static void LatePatch() + { + InitDirtyAction(); + + // 1. Sync direct gear commands + var gearCommandsType = AccessTools.TypeByName("TrueRPGInventory.GearCommands"); + if (gearCommandsType != null) + { + MP.RegisterSyncMethod(gearCommandsType, "Wear").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "Equip").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "UnequipToInventory").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "DropAtFeet").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "DropNearby").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "ToggleForced").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "CycleWeapon").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "MakeSidearm").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gearCommandsType, "ToggleStripDesignation").CancelIfAnyArgNull(); + } + else + { + Log.Warning("[Multiplayer Compat] TrueRPGInventory: Could not find TrueRPGInventory.GearCommands"); + } + + // 2. Bypass trade window redirect in multiplayer to keep Multiplayer's trade session working + var tradeRedirectPrefix = AccessTools.DeclaredMethod("TrueRPGInventory.Patch_TradeRedirect:Prefix"); + if (tradeRedirectPrefix != null) + { + MpCompat.harmony.Patch( + tradeRedirectPrefix, + prefix: new HarmonyMethod(typeof(TrueRPGInventory), nameof(CancelTradeRedirectInMp)) + ); + } + else + { + Log.Warning("[Multiplayer Compat] TrueRPGInventory: Could not find TrueRPGInventory.Patch_TradeRedirect:Prefix"); + } + + // 3. Sync GridStateComponent persistent layout and visual settings + gridStateCompType = AccessTools.TypeByName("TrueRPGInventory.GridStateComponent"); + if (gridStateCompType != null) + { + MP.RegisterSyncMethod(gridStateCompType, "SetPos").CancelIfAnyArgNull(); + MP.RegisterSyncMethod(gridStateCompType, "SetHeadgearHidden"); + MP.RegisterSyncMethod(gridStateCompType, "SetShownWeapon").CancelIfAnyArgNull(); + + var positionsField = AccessTools.Field(gridStateCompType, "positions"); + if (positionsField != null) + positionsGetter = AccessTools.FieldRefAccess>(positionsField); + + var shownWeaponsField = AccessTools.Field(gridStateCompType, "shownWeapons"); + if (shownWeaponsField != null) + shownWeaponsGetter = AccessTools.FieldRefAccess>(shownWeaponsField); + + var hiddenHelmetsField = AccessTools.Field(gridStateCompType, "hiddenHelmets"); + if (hiddenHelmetsField != null) + hiddenHelmetsGetter = AccessTools.FieldRefAccess>(hiddenHelmetsField); + + var hiddenEyewearField = AccessTools.Field(gridStateCompType, "hiddenEyewear"); + if (hiddenEyewearField != null) + hiddenEyewearGetter = AccessTools.FieldRefAccess>(hiddenEyewearField); + + var setPosMethod = AccessTools.DeclaredMethod(gridStateCompType, "SetPos"); + if (setPosMethod != null) + MpCompat.harmony.Patch(setPosMethod, postfix: new HarmonyMethod(typeof(TrueRPGInventory), nameof(PostfixSyncCommandDirty))); + + var setHeadgearHiddenMethod = AccessTools.DeclaredMethod(gridStateCompType, "SetHeadgearHidden"); + if (setHeadgearHiddenMethod != null) + MpCompat.harmony.Patch(setHeadgearHiddenMethod, postfix: new HarmonyMethod(typeof(TrueRPGInventory), nameof(PostfixSyncCommandDirty))); + + var setShownWeaponMethod = AccessTools.DeclaredMethod(gridStateCompType, "SetShownWeapon"); + if (setShownWeaponMethod != null) + MpCompat.harmony.Patch(setShownWeaponMethod, postfix: new HarmonyMethod(typeof(TrueRPGInventory), nameof(PostfixSyncCommandDirty))); + } + else + { + Log.Warning("[Multiplayer Compat] TrueRPGInventory: Could not find TrueRPGInventory.GridStateComponent"); + } + + // 4. Invalidate TrueGearTab cache when grid positions or visual flags change + var hashMethod = AccessTools.DeclaredMethod("TrueRPGInventory.TrueGearTab:Hash", new[] { typeof(Pawn) }); + if (hashMethod != null) + { + MpCompat.harmony.Patch( + hashMethod, + postfix: new HarmonyMethod(typeof(TrueRPGInventory), nameof(PostfixHash)) + ); + } + else + { + Log.Warning("[Multiplayer Compat] TrueRPGInventory: Could not find TrueRPGInventory.TrueGearTab:Hash"); + } + + // 5. Sync Dialog_RPGExchange item transfers + var exchangeType = AccessTools.TypeByName("TrueRPGInventory.Dialog_RPGExchange"); + if (exchangeType != null) + { + MP.RegisterSyncMethod(exchangeType, "MoveItemTo").CancelIfAnyArgNull(); + } + else + { + Log.Warning("[Multiplayer Compat] TrueRPGInventory: Could not find TrueRPGInventory.Dialog_RPGExchange"); + } + } + + private static void PostfixSyncCommandDirty() + { + // Only mark UI dirty when executing a synced command received over multiplayer network + if (MP.IsInMultiplayer && MP.IsExecutingSyncCommand) + { + MarkGearDirty(); + } + } + + private static void PostfixHash(Pawn pawn, ref int __result) + { + if (pawn == null || gridStateCompType == null) + return; + + var comp = Current.Game?.GetComponent(gridStateCompType); + if (comp == null) + return; + + int extraHash = 0; + + var list = pawn.inventory?.innerContainer?.InnerListForReading; + if (positionsGetter != null && list != null) + { + var positions = positionsGetter(comp); + if (positions != null) + { + for (int i = 0; i < list.Count; i++) + { + var thing = list[i]; + if (thing != null) + { + if (positions.TryGetValue(thing.thingIDNumber, out int pos)) + extraHash = extraHash * 31 + pos; + else + extraHash = extraHash * 31 - 1; + } + } + } + } + + if (shownWeaponsGetter != null && list != null) + { + var shownWeapons = shownWeaponsGetter(comp); + if (shownWeapons != null) + { + for (int i = 0; i < list.Count; i++) + { + var thing = list[i]; + if (thing != null && shownWeapons.Contains(thing.thingIDNumber)) + { + extraHash = extraHash * 31 + thing.thingIDNumber; + } + } + } + } + + if (hiddenHelmetsGetter != null) + { + var hiddenHelmets = hiddenHelmetsGetter(comp); + if (hiddenHelmets != null && hiddenHelmets.Contains(pawn.thingIDNumber)) + { + extraHash = extraHash * 31 + 1; + } + } + + if (hiddenEyewearGetter != null) + { + var hiddenEyewear = hiddenEyewearGetter(comp); + if (hiddenEyewear != null && hiddenEyewear.Contains(pawn.thingIDNumber)) + { + extraHash = extraHash * 31 + 2; + } + } + + __result = __result * 31 + extraHash; + } + + private static void InitDirtyAction() + { + var notifyGearMutatedMethod = AccessTools.DeclaredMethod("TrueRPGInventory.TrueGearTab:NotifyGearMutated"); + if (notifyGearMutatedMethod != null) + { + markGearDirtyAction = (Action)Delegate.CreateDelegate(typeof(Action), notifyGearMutatedMethod); + } + else + { + var dirtyField = AccessTools.DeclaredField("TrueRPGInventory.TrueGearTab:Dirty"); + if (dirtyField != null) + { + var dirtyRef = AccessTools.StaticFieldRefAccess(dirtyField); + markGearDirtyAction = () => dirtyRef() = true; + } + } + } + + private static void MarkGearDirty() + { + markGearDirtyAction?.Invoke(); + } + + private static bool CancelTradeRedirectInMp() + { + return !MP.IsInMultiplayer; + } + } +} diff --git a/release_bundler.sh b/release_bundler.sh index c824a91..21fde86 100755 --- a/release_bundler.sh +++ b/release_bundler.sh @@ -7,7 +7,7 @@ dotnet build -c Release rm -rf Multiplayer-Compatibility/ mkdir -p Multiplayer-Compatibility -cp -r About Assemblies Referenced Languages Multiplayer-Compatibility +cp -r --no-preserve=all About Assemblies Referenced Languages Multiplayer-Compatibility # Zip for Github releases rm -f Multiplayer-Compatibility.zip