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 extends java.lang.annotation.Annotation> 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 extends java.lang.annotation.Annotation> 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 extends java.lang.annotation.Annotation> 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();
+}