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