diff --git a/src/main/java/noppes/npcs/constants/ScriptContext.java b/src/main/java/noppes/npcs/constants/ScriptContext.java index 942c406e6..59d02d98d 100644 --- a/src/main/java/noppes/npcs/constants/ScriptContext.java +++ b/src/main/java/noppes/npcs/constants/ScriptContext.java @@ -2,65 +2,49 @@ package noppes.npcs.constants; import noppes.npcs.api.event.*; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLDecoder; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; /** * Represents a script context that maps to one or more event interface namespaces. * - * A script context can have multiple namespaces because script editors often - * support hooks from multiple event types. For example, Player scripts support: - * - IPlayerEvent (core player hooks) - * - IAnimationEvent (animation hooks) - * - IPartyEvent (party hooks) - * - ICustomGuiEvent (custom GUI hooks) - * - etc. + * Script contexts are automatically discovered by scanning for {@link ScriptContextMarker} + * annotations on event interfaces. For example: * - * This is a registerable system - any mod can register their own contexts: + *
+ * {@literal @}ScriptContextMarker("NPC")
+ * public interface INpcEvent extends ICustomNPCsEvent { }
+ * 
* - * // Register a new context with multiple namespaces: - * ScriptContext.register("DBC", "IDBCEvent", "IDBCPlayerEvent", "IDBCFormEvent"); - * - * // Or add namespaces to an existing context: - * ScriptContext.PLAYER.addNamespace("IDBCPlayerEvent"); + * Multiple interfaces can share the same context ID - they will be grouped together. + * This is a registerable system - any mod can register their own contexts by annotating + * their event interfaces. */ public class ScriptContext { // Registry of all script contexts private static final Map REGISTRY = new ConcurrentHashMap<>(); + + // Static initializer to scan and register annotated classes + static { + scanAndRegisterAnnotatedClasses(); + } // ==================== BUILT-IN CONTEXTS ==================== + // These are loaded dynamically from annotations, but we keep references for convenience - public static final ScriptContext NPC = register("NPC", - INpcEvent.class, - IProjectileEvent.class - ); - - public static final ScriptContext PLAYER = register("PLAYER", - IPlayerEvent.class, - IAnimationEvent.class, - IPartyEvent.class, - IDialogEvent.class, - IQuestEvent.class, - IFactionEvent.class, - ICustomGuiEvent.class - ); - - public static final ScriptContext BLOCK = register("BLOCK", - IBlockEvent.class - ); - - public static final ScriptContext ITEM = register("ITEM", - IItemEvent.class - ); - - public static final ScriptContext FORGE = register("FORGE", - IForgeEvent.class - ); - - public static final ScriptContext GLOBAL = register("GLOBAL", - "Global" // Special case: Global namespace doesn't have a corresponding event class - ); + public static final ScriptContext NPC = byId("NPC"); + public static final ScriptContext PLAYER = byId("PLAYER"); + public static final ScriptContext BLOCK = byId("BLOCK"); + public static final ScriptContext ITEM = byId("ITEM"); + public static final ScriptContext FORGE = byId("FORGE"); + public static final ScriptContext GLOBAL = byId("GLOBAL"); // ==================== INSTANCE FIELDS ==================== @@ -122,6 +106,155 @@ public class ScriptContext { // ==================== REGISTRATION API ==================== + /** + * Scan the classpath for classes annotated with {@link ScriptContextMarker} + * and automatically register them. + */ + private static void scanAndRegisterAnnotatedClasses() { + try { + // Scan the noppes.npcs.api.event package for annotated interfaces + List> annotatedClasses = findAnnotatedClasses("noppes.npcs.api.event", ScriptContextMarker.class); + + // Group classes by their context ID + Map>> contextGroups = new HashMap<>(); + + for (Class eventClass : annotatedClasses) { + ScriptContextMarker annotation = eventClass.getAnnotation(ScriptContextMarker.class); + if (annotation != null) { + String contextId = annotation.value(); + contextGroups.computeIfAbsent(contextId, k -> new ArrayList<>()).add(eventClass); + } + } + + // Register each context with its grouped classes + for (Map.Entry>> entry : contextGroups.entrySet()) { + String contextId = entry.getKey(); + List> classes = entry.getValue(); + register(contextId, classes.toArray(new Class[0])); + } + + // Special case: GLOBAL context (no annotation needed) + if (!REGISTRY.containsKey("GLOBAL")) { + register("GLOBAL", "Global"); + } + } catch (Exception e) { + // Fallback: if scanning fails, at least register known core events + System.err.println("[ScriptContext] Failed to scan for annotated classes: " + e.getMessage()); + e.printStackTrace(); + fallbackRegistration(); + } + } + + /** + * Find all classes in a package that have a specific annotation. + */ + private static List> findAnnotatedClasses(String packageName, Class annotationClass) throws Exception { + List> annotatedClasses = new ArrayList<>(); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + String packagePath = packageName.replace('.', '/'); + + // Get resources for the package + Enumeration resources = classLoader.getResources(packagePath); + + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + String protocol = resource.getProtocol(); + + if ("file".equals(protocol)) { + // Scanning from file system (development environment) + String filePath = URLDecoder.decode(resource.getFile(), "UTF-8"); + File directory = new File(filePath); + if (directory.exists()) { + findClassesInDirectory(directory, packageName, annotationClass, annotatedClasses); + } + } else if ("jar".equals(protocol)) { + // Scanning from JAR file (production) + String jarPath = resource.getPath().substring(5, resource.getPath().indexOf("!")); + jarPath = URLDecoder.decode(jarPath, "UTF-8"); + findClassesInJar(jarPath, packageName, annotationClass, annotatedClasses); + } + } + + return annotatedClasses; + } + + /** + * Find classes with annotation in a directory (file system). + */ + private static void findClassesInDirectory(File directory, String packageName, + Class annotationClass, + List> result) { + File[] files = directory.listFiles(); + if (files == null) return; + + for (File file : files) { + String fileName = file.getName(); + if (file.isDirectory()) { + findClassesInDirectory(file, packageName + "." + fileName, annotationClass, result); + } else if (fileName.endsWith(".class")) { + String className = packageName + '.' + fileName.substring(0, fileName.length() - 6); + try { + Class clazz = Class.forName(className); + if (clazz.isAnnotationPresent(annotationClass)) { + result.add(clazz); + } + } catch (Throwable e) { + // Skip classes that can't be loaded + } + } + } + } + + /** + * Find classes with annotation in a JAR file. + */ + private static void findClassesInJar(String jarPath, String packageName, + Class annotationClass, + List> result) { + try { + JarFile jarFile = new JarFile(jarPath); + Enumeration entries = jarFile.entries(); + String packagePath = packageName.replace('.', '/'); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String entryName = entry.getName(); + + if (entryName.startsWith(packagePath) && entryName.endsWith(".class")) { + String className = entryName.replace('/', '.').substring(0, entryName.length() - 6); + try { + Class clazz = Class.forName(className); + if (clazz.isAnnotationPresent(annotationClass)) { + result.add(clazz); + } + } catch (Throwable e) { + // Skip classes that can't be loaded + } + } + } + jarFile.close(); + } catch (IOException e) { + System.err.println("[ScriptContext] Failed to scan JAR: " + jarPath); + } + } + + /** + * Fallback registration if classpath scanning fails. + */ + private static void fallbackRegistration() { + try { + register("NPC", INpcEvent.class, IProjectileEvent.class); + register("PLAYER", IPlayerEvent.class, IAnimationEvent.class, IPartyEvent.class, + IDialogEvent.class, IQuestEvent.class, IFactionEvent.class, ICustomGuiEvent.class); + register("BLOCK", IBlockEvent.class); + register("ITEM", IItemEvent.class); + register("FORGE", IForgeEvent.class); + register("GLOBAL", "Global"); + } catch (Exception e) { + System.err.println("[ScriptContext] Fallback registration also failed: " + e.getMessage()); + } + } + /** * Register a new script context with one or more namespaces. * diff --git a/src/main/java/noppes/npcs/constants/ScriptContextMarker.java b/src/main/java/noppes/npcs/constants/ScriptContextMarker.java new file mode 100644 index 000000000..6da6e23ae --- /dev/null +++ b/src/main/java/noppes/npcs/constants/ScriptContextMarker.java @@ -0,0 +1,27 @@ +package noppes.npcs.constants; + +import java.lang.annotation.*; + +/** + * Marks an event interface as belonging to a specific script context. + * + * Usage: + *
+ * {@literal @}ScriptContextMarker("NPC")
+ * public interface INpcEvent extends ICustomNPCsEvent {
+ *     // ...
+ * }
+ * 
+ * + * Multiple interfaces can share the same context name - they will all be + * registered as namespaces for that context. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ScriptContextMarker { + /** + * The script context ID this event interface belongs to. + * Examples: "NPC", "PLAYER", "BLOCK", "ITEM", "FORGE", "GLOBAL" + */ + String value(); +}