using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using BepInEx.Configuration; using HarmonyLib; using MiraAPI.GameModes; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Utilities; using Reactor.Networking.Rpc; using Reactor.Utilities; using TMPro; using UnityEngine; namespace MiraAPI.GameOptions; /// /// Handles s. /// public static class ModdedOptionsManager { private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary TypeToGroup = []; private static readonly HashSet PatchedListTypes = []; internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; internal static readonly Dictionary> GameModeOptionGroups = []; internal static readonly Dictionary ModdedOptions = []; internal static readonly List Groups = []; internal static uint NextId => _nextId++; private static uint _nextId = 1; /// /// Pops a message notification of a setting being changed. /// /// The game's notification popper. /// The key of the option that changed. /// The new value of the option. /// The color of the notification. /// The optional sprite of the notification. /// A flag that indicates if a notification sound should be played. public static void AddSettingsChangeMessage(NotificationPopper notif, StringNames key, string value, Color textColor, TMP_SpriteAsset? sprite, bool playSound = true) { var text = textColor.ToTextColor(); var translatedKey = TranslationController.Instance.GetString(key); var fontTag = ""; var spritePrefix = sprite != null ? $"" : string.Empty; var item = TranslationController.Instance.GetString( StringNames.LobbyChangeSettingNotification, $"{spritePrefix}{fontTag}{text}{translatedKey}", $"{fontTag}{value}"); notif.SettingsChangeMessageLogic(key, item, playSound); } internal static bool RegisterGroup(Type type) { if (TypeToGroup.ContainsKey(type)) { Error($"Group {type.Name} already exists."); return false; } if (Activator.CreateInstance(type) is not AbstractOptionGroup group) { return false; } RegisterGroupInternal(group, type); return true; } internal static bool RegisterGroup(Type type, MiraPluginInfo pluginInfo) { if (TypeToGroup.ContainsKey(type)) { Error($"Group {type.Name} already exists."); return false; } if (Activator.CreateInstance(type) is not AbstractOptionGroup group) { return false; } if (typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType)) { if (GameModeOptionGroups.TryGetValue(group.OptionableType, out var oldList)) { oldList.Add(group); } else { GameModeOptionGroups.Add(group.OptionableType, [group]); } } pluginInfo.InternalOptionGroups.Add(group); RegisterGroupInternal(group, type); return true; } [SuppressMessage( "Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "Dynamic singleton initialization requires reflection to bypass the private field access modifier because the type is only known at runtime.")] private static void RegisterGroupInternal(AbstractOptionGroup group, Type type) { Groups.Add(group); TypeToGroup.Add(type, group); typeof(OptionGroupSingleton<>).MakeGenericType(type) .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! .SetValue(null, group); } internal static void RegisterPropertyOption(Type type, PropertyInfo property, MiraPluginInfo pluginInfo) { if (!TypeToGroup.TryGetValue(type, out var group)) { Error($"Failed to get group for {type.Name}"); return; } if (property.GetValue(group) is not IModdedOption option) { Error($"Failed to get option for {property.Name}"); return; } RegisterOption(option, group, property.Name, pluginInfo); } internal static void RegisterAttributeOption( Type type, ModdedOptionAttribute attribute, PropertyInfo property, MiraPluginInfo pluginInfo) { if (OptionAttributes.ContainsKey(property)) { Error($"Property {property.Name} already has an attribute registered."); return; } if (!TypeToGroup.TryGetValue(type, out var group)) { Error($"Failed to get group for {type.Name}"); return; } var option = attribute.CreateOption(property.GetValue(group), property); if (option == null) { Error($"Failed to get option for {property.Name}"); return; } var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); } OptionAttributes.Add(property, attribute); attribute.HolderOption = option; attribute.ValueType = property.PropertyType; var visibilityAttr = property.GetCustomAttribute(); var visibilityFunc = visibilityAttr?.GetVisibility(group, property); if (visibilityFunc != null) { option.Visible = visibilityFunc; } var getterAtr = property.GetCustomAttribute(); var getterFunc = getterAtr?.GetGetter(group, property, option); if (getterFunc != null) { option.Getter = getterFunc; } var setterAtr = property.GetCustomAttribute(); var setterFunc = setterAtr?.GetSetter(group, property, option); if (setterFunc != null) { option.Setter = setterFunc; } var changedAtr = property.GetCustomAttribute(); var changedFunc = changedAtr?.GetChanged(group, property, option); if (changedFunc != null) { option.ChangedEvent = changedFunc; } RegisterOption(option, group, property.Name, pluginInfo); } internal static void RegisterPropertyOptionList(Type type, PropertyInfo property, MiraPluginInfo pluginInfo) { if (!TypeToGroup.TryGetValue(type, out var group)) { Error($"Failed to get group for {type.Name}"); return; } if (property.GetValue(group) is not IModdedOptionList optionList) { Error($"Failed to get option list for {property.Name}"); return; } var configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); for (var i = 0; i < optionList.Count; i++) { RegisterOption(optionList[i], group, configName(i), pluginInfo); } } internal static void RegisterAttributeOptionList( Type type, ModdedOptionListAttribute attribute, PropertyInfo property, MiraPluginInfo pluginInfo) { if (OptionAttributes.ContainsKey(property)) { Error($"Property {property.Name} already has an attribute registered."); return; } if (!TypeToGroup.TryGetValue(type, out var group)) { Error($"Failed to get group for {type.Name}"); return; } var propertyVal = property.GetValue(group); if (propertyVal == null) { Error("Cannot initialize option list with null value."); return; } var propertyList = (IList)propertyVal; var optionList = attribute.CreateOptionList(propertyList, property); if (optionList == null) { Error($"Failed to get option for {property.Name}"); return; } if (propertyList.Count != optionList.Count) { Error("Mismatch in count between values and created options."); return; } var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); } var listType = propertyList.GetType(); if (PatchedListTypes.Add(listType)) { var listIndex = listType.GetProperty("Item")!; var listGetterOriginal = listIndex.GetGetMethod(true); var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); var listSetterOriginal = listIndex.GetSetMethod(true); if (listSetterOriginal != null) { var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); } } OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; attribute.Value = (IList)propertyVal; attribute.ValueType = listType.GetGenericArguments()[0]; var valueType = property.PropertyType.GetGenericArguments()[0]; var visibilityAttr = property.GetCustomAttribute(); var visibilityFactory = visibilityAttr?.GetListVisibility(group, property); var getterAttr = property.GetCustomAttribute(); var getterFactory = getterAttr?.GetListGetter(group, property, valueType); var setterAttr = property.GetCustomAttribute(); var setterFactory = setterAttr?.GetListSetter(group, property, valueType); var changedAttr = property.GetCustomAttribute(); var changedFactory = changedAttr?.GetListChanged(group, property, valueType); for (var i = 0; i < optionList.Count; i++) { var option = optionList[i]; if (visibilityFactory != null) { option.Visible = (Func)visibilityFactory(i); } if (getterFactory != null) { option.Getter = getterFactory(i); } if (setterFactory != null) { option.Setter = setterFactory(i); } if (changedFactory != null) { option.ChangedEvent = changedFactory(i); } RegisterOption(option, group, property.Name + (i + 1), pluginInfo); } } internal static void RegisterOption( IModdedOption option, AbstractOptionGroup group, string propertyName, MiraPluginInfo pluginInfo) { var groupName = group.GetType().FullName; option.ConfigDefinition = new ConfigDefinition(groupName, propertyName); option.ParentGroup = group; option.ParentMod = pluginInfo.MiraPlugin; pluginInfo.InternalOptions.Add(option); ModdedOptions.Add(option.Id, option); group.Options.Add(option); } internal static void SyncAllOptions(int targetId = -1) { var chunks = ModdedOptions.Values.Select(option => option.GetNetData()).ChunkNetData(1000); while (chunks.Count > 0) { Rpc.Instance.SendTo(PlayerControl.LocalPlayer, targetId, chunks.Dequeue()); } } internal static void HandleSyncOptions(NetData[] data) { // necessary to disable then re-enable this setting // we don't know how other plugins handle their configs // this way, all the options are saved at once, instead of one by one var oldConfigSetting = new Dictionary(); foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { oldConfigSetting.Add(plugin, plugin.PluginConfig.SaveOnConfigSet); plugin.PluginConfig.SaveOnConfigSet = false; } foreach (var netData in data) { if (!ModdedOptions.TryGetValue(netData.Id, out var option)) { continue; } option.HandleNetData(netData.Data); } foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { plugin.PluginConfig.Save(); plugin.PluginConfig.SaveOnConfigSet = oldConfigSetting[plugin]; } if (LobbyInfoPane.Instance) { LobbyInfoPane.Instance.RefreshPane(); } } /// /// Patches the setter of a property to update the value of the option. /// /// The original setter method. /// The new object value. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention.")] [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertySetterPatch(MethodBase __originalMethod, object value) { var attribute = OptionAttributes.First(pair => pair.Key.GetSetMethod() == __originalMethod).Value; attribute.SetValue(value); } /// /// Patches the getter of a property to return the value of the option. /// /// The original getter method. /// The result of the property getter. /// so the original getter gets skipped. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyGetterPatch(MethodBase __originalMethod, ref object __result) { var attribute = OptionAttributes.First(pair => pair.Key.GetGetMethod() == __originalMethod).Value; __result = attribute.GetValue(); return false; } /// /// Patches the setter of a list property to update the value of the option. /// /// The list's instance. /// The index to find in the list. /// The new object value. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertyListSetterPatch(object __instance, int index, object value) { var match = OptionAttributes.Values .OfType() .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); match?.SetValue(index, value); } /// /// Patches the getter of a list property to return the value of the option. /// /// The list's instance. /// The index to find in the list. /// The result of the property getter. /// False so the original getter gets skipped. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyListGetterPatch(object __instance, int index, ref object __result) { var match = OptionAttributes.Values .OfType() .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); if (match is null) return true; __result = match.GetValue(index); return false; } }