From 9bc78b325317487a8d5c1d532822e5ab33bafaba Mon Sep 17 00:00:00 2001
From: Jumble <113152254+JumbleBumble@users.noreply.github.com>
Date: Fri, 13 Mar 2026 14:10:03 -0400
Subject: [PATCH] Add product effect override callbacks for players/NPCs
Implement a callback system in ProductManager to allow registration, removal, and clearing of custom effect handlers for both player and NPC product effects. Add Harmony patches to intercept effect application and invoke registered callbacks, with optional fallback to base behavior. Update documentation with usage examples for the new override system, enabling modders to customize or extend product effect logic.
---
.../Internal/Patches/ProductEffectPatches.cs | 270 ++++++++++++++++++
S1API/Products/ProductManager.cs | 219 ++++++++++++++
S1API/docs/products-api.md | 56 ++++
3 files changed, 545 insertions(+)
create mode 100644 S1API/Internal/Patches/ProductEffectPatches.cs
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