diff --git a/src/main/java/noppes/npcs/controllers/data/IScriptHandler.java b/src/main/java/noppes/npcs/controllers/data/IScriptHandler.java index cc47eb1c8..b89c725a9 100644 --- a/src/main/java/noppes/npcs/controllers/data/IScriptHandler.java +++ b/src/main/java/noppes/npcs/controllers/data/IScriptHandler.java @@ -17,9 +17,52 @@ public interface IScriptHandler { callScript(type.function, event); } - + void callScript(String hookName, Event event); + /** + * Call a function by name and return its result. + * + * Default implementation calls the function on each script unit (in order) and returns + * the first non-null value. + */ + default Object callFunction(String hookName, Object... args) { + if (hookName == null || hookName.isEmpty()) + return null; + + if (!getEnabled()) + return null; + + List scripts = getScripts(); + if (scripts == null || scripts.isEmpty()) + return null; + + for (IScriptUnit script : scripts) { + if (script == null || script.hasErrored() || !script.hasCode()) + continue; + + Object result = script.callFunction(hookName, args); + if (result != null) + return result; + } + + return null; + } + + /** + * Typed convenience wrapper around {@link #callFunction(String, Object...)}. + */ + default S callFunction(String hookName, Class returnType, Object... args) { + Object result = callFunction(hookName, args); + if (result == null || returnType == null) + return null; + + if (returnType.isInstance(result)) + return returnType.cast(result); + + return null; + } + default boolean isClient() { return FMLCommonHandler.instance().getEffectiveSide().isClient(); } diff --git a/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java b/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java index 1cd661a6e..258b584b8 100644 --- a/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java +++ b/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java @@ -161,8 +161,6 @@ public interface IScriptUnit { /** * Execute this script unit for the given hook type and event. - * For ScriptContainer: calls run(EnumScriptType, Event) - * For JaninoScript: calls callByHookType(EnumScriptType, Event) * * @param type The script hook type * @param event The event object @@ -171,11 +169,32 @@ public interface IScriptUnit { /** * Execute this script unit for the given hook name and event. - * For ScriptContainer: calls run(String, Event) - * For JaninoScript: converts hookName to EnumScriptType and calls callByHookType - * + * * @param hookName The hook name (e.g., "init", "interact") * @param event The event object */ void run(String hookName, Object event); + + /** + * Call a script function by name and return its result. + * + * Default implementation returns null (not supported). + */ + default Object callFunction(String hookName, Object... args) { + return null; + } + + /** + * Typed convenience wrapper around {@link #callFunction(String, Object...)}. + */ + default S callFunction(String hookName, Class returnType, Object... args) { + Object result = callFunction(hookName, args); + if (result == null || returnType == null) + return null; + + if (returnType.isInstance(result)) + return returnType.cast(result); + + return null; + } } diff --git a/src/main/java/noppes/npcs/controllers/data/JaninoScriptHandler.java b/src/main/java/noppes/npcs/controllers/data/JaninoScriptHandler.java index 4dfd5540f..28bd3af4f 100644 --- a/src/main/java/noppes/npcs/controllers/data/JaninoScriptHandler.java +++ b/src/main/java/noppes/npcs/controllers/data/JaninoScriptHandler.java @@ -124,6 +124,13 @@ public class JaninoScriptHandler> extends ScriptHandle deleteScript(); } + public List getHooks() { + if (script != null) + return script.getHookList(); + + return Collections.emptyList(); + } + @Override public IScriptUnit createJaninoScriptUnit() { return factory.get(); diff --git a/src/main/java/noppes/npcs/janino/JaninoHookResolver.java b/src/main/java/noppes/npcs/janino/JaninoHookResolver.java new file mode 100644 index 000000000..2c8eeb543 --- /dev/null +++ b/src/main/java/noppes/npcs/janino/JaninoHookResolver.java @@ -0,0 +1,319 @@ +package noppes.npcs.janino; + +import noppes.npcs.constants.EnumScriptType; +import noppes.npcs.janino.annotations.ScriptHook; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Resolves script hook methods for a given interface type using runtime + * argument values. Resolution favors exact matches and then the "closest" + * assignable parameter types (measured by inheritance distance). + * + * The resolver keeps simple caches for previously resolved signatures and + * supports mapping enum-based hook names (via the {@code @ScriptHook} + * annotation) to methods. + */ +public final class JaninoHookResolver { + private final Class interfaceType; + + /* Methods explicitly mapped from EnumScriptType via @ScriptHook */ + private final Map enumHookLookup = new HashMap<>(); + + /** + * Cache for resolved hook methods. Keys are generated by {@link #buildSignatureKey} + * and represent the unique combination of hook name and runtime argument types. + * This avoids expensive recursive reflection lookups on every hook call. + */ + private final Map signatureCache = new HashMap<>(); + + /* Lazily built list of available hooks and a mapping for display names */ + private boolean hookListInitialized; + /* List of names displayed in GUI hooks list (accounts for overload names) */ + private final List displayHookList = new ArrayList<>(); + /* Display name -> Method map */ + private final Map displayHookMethods = new HashMap<>(); + + /** + * Create a resolver for the provided script interface type. + * @param interfaceType non-null interface class that scripts implement + */ + public JaninoHookResolver(Class interfaceType) { + this.interfaceType = Objects.requireNonNull(interfaceType, "interfaceType"); + initializeEnumHookMethods(); + } + + /** + * Clears internal resolution caches so future calls perform fresh + * method resolution. Call when scripts or available methods change. + */ + public void clearResolutionCaches() { + signatureCache.clear(); + } + + /** + * Resolve a hook method by name and runtime argument values. + * + * This first checks any enum-mapped methods (via {@code @ScriptHook}), + * then a signature-based cache and finally attempts full candidate + * resolution using {@link #resolveHookMethodInternal}. + * + * @param hookName hook method name (or enum function name) + * @param args runtime arguments to use for overload resolution + * @return the resolved {@link Method} or {@code null} if none matched + */ + public Method resolveHookMethod(String hookName, Object[] args) { + if (hookName == null || hookName.isEmpty()) + return null; + + Method method = enumHookLookup.get(hookName); + if (method != null) + return method; + + Object[] safeArgs = args == null ? new Object[0] : args; + + String signatureKey = buildSignatureKey(hookName, safeArgs); + method = signatureCache.get(signatureKey); + if (method != null) + return method; + + method = resolveHookMethodInternal(interfaceType, hookName, safeArgs); + if (method != null) { + signatureCache.put(signatureKey, method); + return method; + } + + String noArgSignatureKey = buildSignatureKey(hookName, new Object[0]); + method = signatureCache.get(noArgSignatureKey); + if (method != null) + return method; + + method = resolveHookMethodInternal(interfaceType, hookName, new Object[0]); + if (method != null) { + signatureCache.put(noArgSignatureKey, method); + return method; + } + + return null; + } + + /** + * Returns an immutable copy of the available hook display names. + */ + public List getDisplayHookList() { + ensureHookListCreated(); + return new ArrayList<>(displayHookList); + } + + public Method getMethodForDisplayName(String displayName) { + if (displayName == null || displayName.isEmpty()) + return null; + + ensureHookListCreated(); + return displayHookMethods.get(displayName); + } + + /** + * Builds the human-readable hook list and display-name -> Method map. + * The list is computed lazily on first access and cached. + */ + private void ensureHookListCreated() { + if (hookListInitialized) + return; + + Map> grouped = Arrays.stream(interfaceType.getDeclaredMethods()) + .collect(Collectors.groupingBy(Method::getName)); + + for (Map.Entry> entry : grouped.entrySet()) { + List methods = entry.getValue(); + + methods.sort(Comparator.comparingInt(Method::getParameterCount)); + + boolean first = true; + for (Method m : methods) { + if (Modifier.isFinal(m.getModifiers())) + continue; + + String displayName = first ? m.getName() : + m.getName() + "(" + Arrays.stream(m.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(", ")) + ")"; + + displayHookList.add(displayName); + displayHookMethods.put(displayName, m); + first = false; + } + } + + hookListInitialized = true; + } + + /** + * Maps EnumScriptType common names to their respective methods using + * the {@code @ScriptHook} annotation. + */ + private void initializeEnumHookMethods() { + for (Method method : interfaceType.getDeclaredMethods()) { + ScriptHook annotation = method.getAnnotation(ScriptHook.class); + if (annotation == null) + continue; + + for (EnumScriptType hookType : annotation.value()) { + enumHookLookup.put(hookType.function, method); + } + } + } + + /** + * Build a cache key for a hook name and runtime argument list. This key + * encodes the runtime classes of the provided arguments and is used for + * quick cache lookups. Note: keys are based on concrete runtime classes + * which may cause fragmentation when many subclasses are used. + * + * @param hookName hook name + * @param args runtime arguments + * @return a string key representing the hook signature + */ + public static String buildSignatureKey(String hookName, Object[] args) { + StringBuilder sb = new StringBuilder(); + sb.append(hookName).append('#'); + + if (args == null || args.length == 0) + return sb.append('0').toString(); + + sb.append(args.length).append(':'); + for (int i = 0; i < args.length; i++) { + Object arg = args[i]; + if (i > 0) + sb.append(','); + + sb.append(arg == null ? "null" : arg.getClass().getName()); + } + return sb.toString(); + } + + /** + * Internal resolution routine. Attempts to find the best matching method + * on the provided interface by comparing parameter counts and a simple + * match scoring function (see {@link #scoreMethodMatch}). + */ + private static Method resolveHookMethodInternal(Class interfaceType, String hookName, Object[] args) { + Object[] safeArgs = args == null ? new Object[0] : args; + + Method best = null; + int bestScore = Integer.MAX_VALUE; + + for (Method method : interfaceType.getDeclaredMethods()) { + if (!method.getName().equals(hookName) || Modifier.isFinal(method.getModifiers())) + continue; + + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length != safeArgs.length) + continue; + + int score = scoreMethodMatch(paramTypes, safeArgs); + if (score == Integer.MAX_VALUE) + continue; + + if (score < bestScore) { + bestScore = score; + best = method; + if (score == 0) + return best; + } + } + + return best; + } + + /** + * Score how well a method's parameter types match the provided runtime + * arguments. Lower scores are better; {@link Integer#MAX_VALUE} indicates + * an incompatible parameter. + * + * @param paramTypes method parameter types + * @param args runtime argument values + * @return score (lower is better) or Integer.MAX_VALUE for no match + */ + private static int scoreMethodMatch(Class[] paramTypes, Object[] args) { + int score = 0; + for (int i = 0; i < paramTypes.length; i++) { + Class paramType = paramTypes[i]; + Object arg = args[i]; + + if (arg == null) { + if (paramType.isPrimitive()) + return Integer.MAX_VALUE; + + score += 1; + continue; + } + + Class argType = arg.getClass(); + Class boxedParam = boxType(paramType); + + if (boxedParam.equals(argType)) + continue; + + if (boxedParam.isAssignableFrom(argType)) { + score += inheritanceDistance(argType, boxedParam); + continue; + } + + return Integer.MAX_VALUE; + } + + return score; + } + + private static Class boxType(Class type) { + if (!type.isPrimitive()) + return type; + if (type == boolean.class) + return Boolean.class; + if (type == byte.class) + return Byte.class; + if (type == short.class) + return Short.class; + if (type == int.class) + return Integer.class; + if (type == long.class) + return Long.class; + if (type == float.class) + return Float.class; + if (type == double.class) + return Double.class; + if (type == char.class) + return Character.class; + return type; + } + + /** + * Compute a simple inheritance distance between a child and parent class + * by walking superclasses. Returns a large value if {@code parent} is + * not assignable from {@code child}. + * + * Note: the matching algorithm uses superclass traversal to compute a + * distance metric; interface inheritance is not explored which can make + * matches against interfaces less optimal. Consider improving + * `inheritanceDistance` if interface-based scoring is required. + */ + private static int inheritanceDistance(Class child, Class parent) { + if (child.equals(parent)) + return 0; + + if (!parent.isAssignableFrom(child)) + return 1000; + + int distance = 0; + Class current = child; + while (current != null && !current.equals(parent)) { + current = current.getSuperclass(); + distance++; + } + return distance; + } +} diff --git a/src/main/java/noppes/npcs/janino/JaninoScript.java b/src/main/java/noppes/npcs/janino/JaninoScript.java index bc3d8e915..8a217b8d0 100644 --- a/src/main/java/noppes/npcs/janino/JaninoScript.java +++ b/src/main/java/noppes/npcs/janino/JaninoScript.java @@ -1,6 +1,5 @@ package noppes.npcs.janino; -import cpw.mods.fml.common.FMLCommonHandler; import cpw.mods.fml.common.eventhandler.Event; import io.github.somehussar.janinoloader.api.script.IScriptBodyBuilder; import io.github.somehussar.janinoloader.api.script.IScriptClassBody; @@ -12,7 +11,6 @@ import noppes.npcs.constants.EnumScriptType; import noppes.npcs.controllers.ScriptController; import noppes.npcs.controllers.data.IScriptUnit; import noppes.npcs.janino.annotations.ParamName; -import noppes.npcs.janino.annotations.ScriptHook; import org.codehaus.commons.compiler.InternalCompilerException; import org.codehaus.commons.compiler.Sandbox; @@ -50,15 +48,8 @@ public abstract class JaninoScript implements IScriptUnit { public final IScriptBodyBuilder builder; protected IScriptClassBody scriptBody; - private boolean hooksInitialized = false; - // Hook list for GUI display - private List hookList = new ArrayList<>(); - // Map from display name (with parameters if overloaded) to Method - private Map hookMap = new HashMap<>(); - // Map from EnumScriptType to Method for OLD tab-based script system - private Map hookTypeMap = new HashMap<>(); + private final JaninoHookResolver hookResolver; - // Default imports for this script type (e.g., noppes.npcs.api.*) private final String[] defaultImports; protected JaninoScript(Class type, String[] defaultImports) { @@ -73,8 +64,7 @@ public abstract class JaninoScript implements IScriptUnit { this.scriptBody = builder.build(); - // Build hook type map from @ScriptHook annotations - initializeHookTypeMap(); + this.hookResolver = new JaninoHookResolver(type); } protected T getUnsafe() { @@ -147,8 +137,10 @@ public abstract class JaninoScript implements IScriptUnit { appendConsole(parentCause.getMessage()); appendConsole(rootCause.getMessage()); } + hookResolver.clearResolutionCaches(); } catch (Exception e) { appendConsole("Unknown error: " + e.getMessage()); + hookResolver.clearResolutionCaches(); } } @@ -197,54 +189,43 @@ public abstract class JaninoScript implements IScriptUnit { } } - /** - * Scans the Functions interface for @ScriptHook annotations and builds the hookTypeMap. - * This allows direct method invocation by EnumScriptType without switch statements. - */ - private void initializeHookTypeMap() { - for (Method method : type.getDeclaredMethods()) { - ScriptHook annotation = method.getAnnotation(ScriptHook.class); - if (annotation != null) { - for (EnumScriptType hookType : annotation.value()) { - hookTypeMap.put(hookType, method); - } - } - } + public void callByHookName(String hookName, Object arg) { + callFunction(hookName, arg); } - /** - * Call a hook method by its EnumScriptType. - * Used by the OLD tab-based script system to execute hooks directly. - * - * @param hookType The script type/hook to execute - * @param event The event object to pass to the hook method - */ - public void callByHookType(EnumScriptType hookType, Object event) { - Method method = hookTypeMap.get(hookType); - if (method == null) { - // Hook not implemented or not annotated - return; - } + @Override + public Object callFunction(String hookName, Object... args) { + if (hookName == null || hookName.isEmpty()) + return null; + + Method method = hookResolver.resolveHookMethod(hookName, args); + if (method == null) + return null; ensureCompiled(); T t = getUnsafe(); if (t == null) - return; + return null; + + Method invokeMethod = method; + Object[] invokeArgs = args == null ? new Object[0] : args; try { - sandbox.confine((PrivilegedAction) () -> { + return sandbox.confine((PrivilegedAction) () -> { try { - method.invoke(t, event); + return invokeMethod.invoke(t, invokeArgs); } catch (Exception e) { - appendConsole("Error calling hook " + hookType.name() + ": " + e.getMessage()); + appendConsole("Error calling hook " + hookName + ": " + e.getMessage()); + return null; } - return null; }); } catch (Exception e) { - appendConsole("Runtime Error in hook " + hookType.name() + ": " + e.getMessage()); + appendConsole("Runtime Error in hook " + hookName + ": " + e.getMessage()); + return null; } } + // ==================== IScriptUnit IMPLEMENTATION ==================== /** @@ -256,7 +237,10 @@ public abstract class JaninoScript implements IScriptUnit { */ @Override public void run(EnumScriptType type, Event event) { - callByHookType(type, event); + if (type == null) + return; + + callFunction(type.function, event); } /** @@ -268,13 +252,9 @@ public abstract class JaninoScript implements IScriptUnit { */ @Override public void run(String hookName, Object event) { - try { - EnumScriptType enumType = EnumScriptType.valueOfIgnoreCase(hookName); - callByHookType(enumType, event); - } catch (IllegalArgumentException ignored) { - // Hook name doesn't map to an EnumScriptType - } + callFunction(hookName, event); } + @Override public String getScript() { return this.script; @@ -284,6 +264,7 @@ public abstract class JaninoScript implements IScriptUnit { public void setScript(String script) { this.script = script; this.evaluated = false; + hookResolver.clearResolutionCaches(); } @Override @@ -295,8 +276,7 @@ public abstract class JaninoScript implements IScriptUnit { public void setExternalScripts(List externalScripts) { this.externalScripts = externalScripts; this.evaluated = false; - this.hookNameMap.clear(); - this.missingHookNames.clear(); + hookResolver.clearResolutionCaches(); } @Override @@ -343,19 +323,14 @@ public abstract class JaninoScript implements IScriptUnit { return script != null && !script.isEmpty(); } - @Override - public String generateHookStub(String hookName, Object hookData) { - // Build hook list and map - if (!hooksInitialized) { - createHookList(); - hooksInitialized = true; - } - - // Look up in hookMap (handles both simple names and overloaded display names) - Method method = hookMap.get(hookName); - if (method != null) - return generateMethodStub(method); + @Override + public String generateHookStub(String hookName, Object hookData) { + hookResolver.clearResolutionCaches(); + Method method = hookResolver.getMethodForDisplayName(hookName); + if (method != null) + return generateMethodStub(method); + // Fallback for unknown hook names - generate basic override return String.format("public void %s() {\n \n}\n", hookName); } @@ -416,45 +391,8 @@ public abstract class JaninoScript implements IScriptUnit { } - /** - * Create the hook list and hook map for GUI display and generation. - * Groups methods by name and creates display names with parameters for overloaded methods. - */ - private void createHookList() { - // Group all declared methods by their name - Map> grouped = Arrays.stream(type.getDeclaredMethods()) - .collect(Collectors.groupingBy(Method::getName)); - - for (Map.Entry> entry : grouped.entrySet()) { - List methods = entry.getValue(); - - // Sort overloads by parameter count (ascending) - methods.sort(Comparator.comparingInt(Method::getParameterCount)); - boolean first = true; - - for (Method m : methods) { - if (Modifier.isFinal(m.getModifiers())) - continue; // Skip final methods - - // For first (simplest) overload, use just the method name - // For additional overloads, include parameter types in display name - String displayName = first ? m.getName() : - m.getName() + "(" + Arrays.stream(m.getParameterTypes()) - .map(Class::getSimpleName) - .collect(Collectors.joining(", ")) + ")"; - - hookList.add(displayName); - hookMap.put(displayName, m); - first = false; - } - } - } - - /** - * Get the list of hook names for GUI display. - */ public List getHookList() { - return new ArrayList<>(hookList); + return hookResolver.getDisplayHookList(); }