diff --git a/S1API/Internal/Patches/ProductEffectPatches.cs b/S1API/Internal/Patches/ProductEffectPatches.cs new file mode 100644 index 0000000..19fc19b --- /dev/null +++ b/S1API/Internal/Patches/ProductEffectPatches.cs @@ -0,0 +1,270 @@ +#if (IL2CPPMELON) +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; +using S1NPCs = Il2CppScheduleOne.NPCs; +using S1Product = Il2CppScheduleOne.Product; +using S1Properties = Il2CppScheduleOne.Effects; +#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) +using S1PlayerScripts = ScheduleOne.PlayerScripts; +using S1NPCs = ScheduleOne.NPCs; +using S1Product = ScheduleOne.Product; +using S1Properties = ScheduleOne.Effects; +#endif +using System; +using System.Collections.Generic; +using System.Collections; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using S1API.Entities; +using S1API.Logging; +using S1API.Properties; +using S1API.Products; + +namespace S1API.Internal.Patches +{ + /// + /// INTERNAL: Intercepts product instance effect application and routes through registered callbacks. + /// + [HarmonyPatch] + internal static class ProductEffectPatches + { + private static readonly Log Logger = new Log("ProductEffectPatches"); + private static Dictionary? _npcWrapperTypeById; + + /// + /// Targets all concrete product instance ApplyEffectsToPlayer and ApplyEffectsToNPC implementations. + /// + private static IEnumerable TargetMethods() + { + var playerType = typeof(S1PlayerScripts.Player); + var npcType = typeof(S1NPCs.NPC); + + return typeof(S1Product.ProductItemInstance).Assembly + .GetTypes() + .Where(type => typeof(S1Product.ProductItemInstance).IsAssignableFrom(type)) + .SelectMany(type => new[] + { + type.GetMethod( + "ApplyEffectsToPlayer", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] { playerType }, + null), + type.GetMethod( + "ApplyEffectsToNPC", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] { npcType }, + null) + }) + .Where(method => method != null) + .Cast(); + } + + /// + /// Intercepts product effect application for players and NPCs and executes callbacks per effect ID. + /// Unhandled effects fall back to base game behavior (effect.ApplyToPlayer). + /// + /// The product instance applying effects. + /// The first argument (player or NPC receiving effects). + /// false when handled here; true to run original method. + [HarmonyPrefix] + private static bool ApplyEffects_Prefix(S1Product.ProductItemInstance __instance, object __0) + { + var target = __0; + + if (__instance == null || target == null) + return true; + + var effects = ResolveEffects(__instance); + if (effects == null) + return true; + + var localPlayer = Player.All.FirstOrDefault(p => p.IsLocal); + var targetPlayer = target as S1PlayerScripts.Player; + var targetNpc = target as S1NPCs.NPC; + + if (targetPlayer == null && targetNpc == null) + return true; + + if (targetPlayer != null && (localPlayer == null || targetPlayer != localPlayer.S1Player)) + return true; + + var invokedEffectIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < effects.Count; i++) + { + var effect = effects[i]; + if (effect == null) + continue; + + var effectId = effect.ID; + if (string.IsNullOrWhiteSpace(effectId) || !invokedEffectIds.Add(effectId)) + continue; + + try + { + if (targetPlayer != null) + { + var handled = ProductManager.TryInvokeEffectCallback(effectId, localPlayer!, out var allowDefaultEffect); + if (!handled || allowDefaultEffect) + effect.ApplyToPlayer(targetPlayer); + } + else + { + var apiNpc = ResolveApiNpc(targetNpc); + + var allowDefaultEffect = false; + var handled = apiNpc != null && ProductManager.TryInvokeNpcEffectCallback(effectId, apiNpc, out allowDefaultEffect); + if (!handled || allowDefaultEffect) + effect.ApplyToNPC(targetNpc); + } + } + catch (Exception ex) + { + Logger.Error($"Exception while invoking effect callback for '{effectId}': {ex.Message}"); + Logger.Error(ex.StackTrace ?? string.Empty); + } + } + + return false; + } + + private static List? ResolveEffects(S1Product.ProductItemInstance productInstance) + { + try + { + var wrappedInstance = new ProductInstance(productInstance); + var fromWrapper = wrappedInstance.Properties + .OfType() + .Select(property => property.InnerProperty) + .Where(effect => effect != null) + .ToList(); + + if (fromWrapper.Count > 0) + return fromWrapper; + } + catch (Exception ex) + { + Logger.Error($"ResolveEffects wrapper path failed: {ex.Message}"); + } + + var fromInstance = ExtractEffectsFromObject(TryGetPropertyValue(productInstance, "Properties")); + if (fromInstance != null && fromInstance.Count > 0) + return fromInstance; + + var definition = productInstance.Definition; + var fromDefinition = ExtractEffectsFromObject(TryGetPropertyValue(definition, "Properties")); + if (fromDefinition != null && fromDefinition.Count > 0) + return fromDefinition; + + return null; + } + + private static object? TryGetPropertyValue(object? instance, string propertyName) + { + if (instance == null) + return null; + + var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return property?.GetValue(instance); + } + + private static List? ExtractEffectsFromObject(object? propertiesObject) + { + if (propertiesObject == null) + return null; + + var results = new List(); + + if (propertiesObject is IEnumerable enumerable) + { + foreach (var entry in enumerable) + { + if (entry is S1Properties.Effect effect) + results.Add(effect); + } + } + + return results; + } + + private static NPC? ResolveApiNpc(S1NPCs.NPC? targetNpc) + { + if (targetNpc == null) + return null; + + var byRefOrId = NPC.All.FirstOrDefault(npc => + npc?.S1NPC == targetNpc || + (!string.IsNullOrWhiteSpace(npc?.S1NPC?.ID) && + !string.IsNullOrWhiteSpace(targetNpc.ID) && + string.Equals(npc.S1NPC.ID, targetNpc.ID, StringComparison.OrdinalIgnoreCase))); + + if (byRefOrId != null) + return byRefOrId; + + if (string.IsNullOrWhiteSpace(targetNpc.ID)) + return null; + + try + { + var wrapperTypeById = GetNpcWrapperTypeById(); + if (!wrapperTypeById.TryGetValue(targetNpc.ID, out var wrapperType)) + return null; + + var created = Activator.CreateInstance(wrapperType, nonPublic: true) as NPC; + if (created?.S1NPC == targetNpc) + return created; + + return NPC.All.FirstOrDefault(npc => npc?.S1NPC == targetNpc); + } + catch (Exception ex) + { + Logger.Error($"NPC intercept: wrapper creation failed for '{targetNpc.ID}': {ex.Message}"); + return null; + } + } + + private static Dictionary GetNpcWrapperTypeById() + { + if (_npcWrapperTypeById != null) + return _npcWrapperTypeById; + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var npcBaseType = typeof(NPC); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch + { + continue; + } + + foreach (var type in types) + { + if (type == null || type.IsAbstract || !npcBaseType.IsAssignableFrom(type)) + continue; + + var npcIdProperty = type.GetProperty("NPCId", BindingFlags.Public | BindingFlags.Static); + if (npcIdProperty == null || npcIdProperty.PropertyType != typeof(string)) + continue; + + var npcId = npcIdProperty.GetValue(null) as string; + if (string.IsNullOrWhiteSpace(npcId)) + continue; + + if (!map.ContainsKey(npcId)) + map[npcId] = type; + } + } + + _npcWrapperTypeById = map; + return map; + } + } +} diff --git a/S1API/Products/ProductManager.cs b/S1API/Products/ProductManager.cs index 8163302..e62d730 100644 --- a/S1API/Products/ProductManager.cs +++ b/S1API/Products/ProductManager.cs @@ -3,7 +3,11 @@ #elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) using S1Product = ScheduleOne.Product; #endif +using System; +using System.Collections.Generic; using System.Linq; +using S1API.Entities; +using S1API.Properties.Interfaces; namespace S1API.Products { @@ -12,6 +16,35 @@ namespace S1API.Products /// public static class ProductManager { + private sealed class EffectCallbackRegistration + { + internal EffectCallbackRegistration(Action callback, bool allowDefaultEffect) + { + Callback = callback; + AllowDefaultEffect = allowDefaultEffect; + } + + internal Action Callback { get; } + + internal bool AllowDefaultEffect { get; } + } + + private sealed class NpcEffectCallbackRegistration + { + internal NpcEffectCallbackRegistration(Action callback, bool allowDefaultEffect) + { + Callback = callback; + AllowDefaultEffect = allowDefaultEffect; + } + + internal Action Callback { get; } + + internal bool AllowDefaultEffect { get; } + } + + private static readonly Dictionary EffectCallbacks = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary NpcEffectCallbacks = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// /// Minimum price for any product (1). /// @@ -78,5 +111,191 @@ public static class ProductManager /// The calculated value. public static float CalculateProductValue(ProductDefinition product, float baseValue) => S1Product.ProductManager.CalculateProductValue(product.S1ProductDefinition, baseValue); + + /// + /// Registers or replaces a callback for a product effect. + /// When this effect triggers on the local player, the callback is invoked and can optionally allow base behavior. + /// + /// The product effect/property to override. + /// The callback to invoke with the local player. + /// + /// If true, the base game effect is also applied. + /// If false, only the callback runs for this effect. + /// + public static void SetEffectCallback(PropertyBase property, Action callback, bool allowDefaultEffect = false) + { + if (property == null) + throw new ArgumentNullException(nameof(property)); + + SetEffectCallback(property.ID, callback, allowDefaultEffect); + } + + /// + /// Registers or replaces a callback for a product effect ID. + /// When this effect triggers on the local player, the callback is invoked and can optionally allow base behavior. + /// + /// The product effect ID. + /// The callback to invoke with the local player. + /// + /// If true, the base game effect is also applied. + /// If false, only the callback runs for this effect. + /// + public static void SetEffectCallback(string effectId, Action callback, bool allowDefaultEffect = false) + { + if (string.IsNullOrWhiteSpace(effectId)) + throw new ArgumentException("Effect ID cannot be null or whitespace.", nameof(effectId)); + + if (callback == null) + throw new ArgumentNullException(nameof(callback)); + + EffectCallbacks[effectId] = new EffectCallbackRegistration(callback, allowDefaultEffect); + } + + /// + /// Removes an effect callback by property. + /// + /// The product effect/property to remove. + /// true if a callback was removed; otherwise false. + public static bool RemoveEffectCallback(PropertyBase property) + { + if (property == null) + throw new ArgumentNullException(nameof(property)); + + return RemoveEffectCallback(property.ID); + } + + /// + /// Removes an effect callback by effect ID. + /// + /// The product effect ID. + /// true if a callback was removed; otherwise false. + public static bool RemoveEffectCallback(string effectId) + { + if (string.IsNullOrWhiteSpace(effectId)) + return false; + + return EffectCallbacks.Remove(effectId); + } + + /// + /// Removes all registered product effect callbacks. + /// + public static void ClearEffectCallbacks() => + EffectCallbacks.Clear(); + + /// + /// Registers or replaces an NPC callback for a product effect. + /// When this effect triggers on an NPC, the callback is invoked and can optionally allow base behavior. + /// + /// The product effect/property to override. + /// The callback to invoke with the target NPC. + /// + /// If true, the base game effect is also applied. + /// If false, only the callback runs for this effect. + /// + public static void SetNpcEffectCallback(PropertyBase property, Action callback, bool allowDefaultEffect = false) + { + if (property == null) + throw new ArgumentNullException(nameof(property)); + + SetNpcEffectCallback(property.ID, callback, allowDefaultEffect); + } + + /// + /// Registers or replaces an NPC callback for a product effect ID. + /// When this effect triggers on an NPC, the callback is invoked and can optionally allow base behavior. + /// + /// The product effect ID. + /// The callback to invoke with the target NPC. + /// + /// If true, the base game effect is also applied. + /// If false, only the callback runs for this effect. + /// + public static void SetNpcEffectCallback(string effectId, Action callback, bool allowDefaultEffect = false) + { + if (string.IsNullOrWhiteSpace(effectId)) + throw new ArgumentException("Effect ID cannot be null or whitespace.", nameof(effectId)); + + if (callback == null) + throw new ArgumentNullException(nameof(callback)); + + NpcEffectCallbacks[effectId] = new NpcEffectCallbackRegistration(callback, allowDefaultEffect); + } + + /// + /// Removes an NPC effect callback by property. + /// + /// The product effect/property to remove. + /// true if a callback was removed; otherwise false. + public static bool RemoveNpcEffectCallback(PropertyBase property) + { + if (property == null) + throw new ArgumentNullException(nameof(property)); + + return RemoveNpcEffectCallback(property.ID); + } + + /// + /// Removes an NPC effect callback by effect ID. + /// + /// The product effect ID. + /// true if a callback was removed; otherwise false. + public static bool RemoveNpcEffectCallback(string effectId) + { + if (string.IsNullOrWhiteSpace(effectId)) + return false; + + return NpcEffectCallbacks.Remove(effectId); + } + + /// + /// Removes all registered NPC product effect callbacks. + /// + public static void ClearNpcEffectCallbacks() => + NpcEffectCallbacks.Clear(); + + /// + /// INTERNAL: Tries to invoke a registered product effect callback. + /// + /// The product effect ID. + /// The local player wrapper to pass into callback. + /// Outputs whether the base game effect should also be applied. + /// true if a callback was found and invoked; otherwise false. + internal static bool TryInvokeEffectCallback(string effectId, Player player, out bool allowDefaultEffect) + { + allowDefaultEffect = false; + + if (string.IsNullOrWhiteSpace(effectId) || player == null) + return false; + + if (!EffectCallbacks.TryGetValue(effectId, out var registration) || registration?.Callback == null) + return false; + + allowDefaultEffect = registration.AllowDefaultEffect; + registration.Callback(player); + return true; + } + + /// + /// INTERNAL: Tries to invoke a registered NPC product effect callback. + /// + /// The product effect ID. + /// The NPC wrapper to pass into callback. + /// Outputs whether the base game effect should also be applied. + /// true if a callback was found and invoked; otherwise false. + internal static bool TryInvokeNpcEffectCallback(string effectId, NPC npc, out bool allowDefaultEffect) + { + allowDefaultEffect = false; + + if (string.IsNullOrWhiteSpace(effectId) || npc == null) + return false; + + if (!NpcEffectCallbacks.TryGetValue(effectId, out var registration) || registration?.Callback == null) + return false; + + allowDefaultEffect = registration.AllowDefaultEffect; + registration.Callback(npc); + return true; + } } } diff --git a/S1API/docs/products-api.md b/S1API/docs/products-api.md index 62b16ed..9786066 100644 --- a/S1API/docs/products-api.md +++ b/S1API/docs/products-api.md @@ -79,6 +79,62 @@ foreach (var prop in def.Properties) } ``` +## Overriding product effect behavior with callbacks + +You can register callbacks for both player and NPC product effects. + +### Player callbacks + +By default, callbacks replace the base effect behavior: + +```csharp +using S1API.Products; +using S1API.Properties; + +ProductManager.SetEffectCallback(Property.Euphoric, player => +{ + // Custom behavior instead of the base effect + player.Heal(10); +}); + +// Optional: run callback AND keep default effect behavior +ProductManager.SetEffectCallback(Property.Euphoric, player => +{ + player.Heal(5); +}, allowDefaultEffect: true); + +// Remove later if needed +ProductManager.RemoveEffectCallback(Property.Euphoric); +``` + +Use `ProductManager.ClearEffectCallbacks()` to remove all registered overrides. + +### NPC callbacks + +You can also intercept effects applied through `ApplyEffectsToNPC`: + +```csharp +using S1API.Products; +using S1API.Properties; + +ProductManager.SetNpcEffectCallback(Property.Sneaky, npc => +{ + // Custom NPC effect behavior + npc.Heal(5f); +}); + +// Optional: run callback AND keep default effect behavior +ProductManager.SetNpcEffectCallback(Property.Sneaky, npc => +{ + npc.Heal(2f); +}, allowDefaultEffect: true); + +// Remove later if needed +ProductManager.RemoveNpcEffectCallback(Property.Sneaky); +``` + +Use `ProductManager.ClearNpcEffectCallbacks()` to remove all registered NPC overrides. + ## Creating product instances ### Unpackaged