diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a29d18e..ddde6af 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -73,17 +73,6 @@ jobs:
USER_GITHUB_NAME: ${{ github.actor }}
USER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # 4d. Auto Create Tag
- # Automatically creates a git tag after successful 4c publish.
- - name: Create Git Tag
- if: ${{ steps.publish_release.outcome == 'success' }}
- run: |
- TAG_NAME="v${{ steps.check_iteration.outputs.version }}"
- git tag $TAG_NAME
- git push origin $TAG_NAME
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
@@ -94,3 +83,4 @@ jobs:
!**/build/libs/*-sources.jar
!**/build/libs/*-javadoc.jar
retention-days: 1
+
diff --git a/gradle.properties b/gradle.properties
index 084771c..2500c0f 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,10 +4,9 @@ git-org-repository=mcextension
# --- Artifact Identity ---
project-version=2026.0.2
-project-iteration=4
+project-iteration=5
project-group=io.github.mcengine
project-artifact-id=mcextension
project-artifact-name=MCExtension
project-artifact-description=This project is a library designed to allow Minecraft plugins to load their own extensions.
project-artifact-url=https://mcengine.github.io/mcextension-website
-
diff --git a/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java b/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java
index 2316629..f3e80f3 100644
--- a/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java
+++ b/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java
@@ -50,22 +50,4 @@ default void onDisable(JavaPlugin plugin, Executor executor) {}
default boolean checkUpdate(String url, String token) {
return false;
}
-
- /**
- * Gets the unique identifier for this extension.
- *
- * This ID must be unique within the context of the host plugin.
- * If another extension with the same ID is already loaded, this one will be rejected.
- *
- *
- * @return A unique string ID (e.g., "DailyRewards", "CustomItems").
- */
- String getId();
-
- /**
- * Gets the version string of this extension.
- *
- * @return The version string (e.g., "1.0.0").
- */
- String getVersion();
}
diff --git a/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java b/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java
index a86d00b..c203cc0 100644
--- a/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java
+++ b/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java
@@ -1,15 +1,16 @@
package io.github.mcengine.mcextension.common;
import io.github.mcengine.mcextension.api.IMCExtension;
+import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
+import org.yaml.snakeyaml.Yaml;
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
import java.util.concurrent.Executor;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
@@ -23,24 +24,38 @@
*/
public class MCExtensionManager {
+ /**
+ * The host plugin instance that owns this manager.
+ */
private final JavaPlugin plugin;
+
+ /**
+ * The directory where extension JAR files are stored.
+ */
private final File extensionFolder;
+
+ /**
+ * The executor used for running extension-related tasks.
+ */
private final Executor executor;
- // Tracks loaded extension metadata: {ID : Version}
+ /**
+ * A map tracking loaded extension metadata, where the key is the extension ID
+ * and the value is the version string.
+ */
private final Map loadedExtensionsInfo = new HashMap<>();
- // Tracks loaded extension instances: {ID : Instance}
+ /**
+ * A map tracking active extension instances, where the key is the extension ID
+ * and the value is the instantiated {@link IMCExtension}.
+ */
private final Map loadedInstances = new HashMap<>();
/**
* Creates a new MCExtensionManager for the given plugin.
- *
- * Extensions will be loaded from: {@code plugins/{PluginName}/extensions/}
- *
*
* @param plugin The host plugin instance.
- * @param executor The executor responsible for handling extension tasks (e.g., Async/Folia scheduler).
+ * @param executor The executor responsible for handling extension tasks.
*/
public MCExtensionManager(JavaPlugin plugin, Executor executor) {
this.plugin = plugin;
@@ -53,7 +68,11 @@ public MCExtensionManager(JavaPlugin plugin, Executor executor) {
}
/**
- * Scans the extension folder and loads all valid .jar files.
+ * Scans the extension folder and loads all valid .jar files with dependency resolution.
+ *
+ * This method uses a multi-pass approach to ensure that extensions are loaded only
+ * after their required dependencies (both base plugins and other extensions) are ready.
+ *
*/
public void loadAllExtensions() {
File[] files = extensionFolder.listFiles((dir, name) -> name.endsWith(".jar"));
@@ -63,18 +82,52 @@ public void loadAllExtensions() {
return;
}
- plugin.getLogger().info("Found " + files.length + " extension(s). Loading...");
+ List pendingFiles = new ArrayList<>(Arrays.asList(files));
+ boolean changed = true;
+
+ plugin.getLogger().info("Found " + files.length + " extension(s). Resolving dependencies...");
- for (File file : files) {
- try {
- loadExtension(file);
- } catch (Exception e) {
- plugin.getLogger().severe("Failed to load extension: " + file.getName());
- e.printStackTrace();
+ while (changed && !pendingFiles.isEmpty()) {
+ changed = false;
+ Iterator iterator = pendingFiles.iterator();
+
+ while (iterator.hasNext()) {
+ File file = iterator.next();
+ try {
+ LoadResult result = loadExtension(file);
+ if (result == LoadResult.SUCCESS) {
+ iterator.remove();
+ changed = true;
+ } else if (result == LoadResult.FAILED) {
+ iterator.remove();
+ }
+ } catch (Exception e) {
+ plugin.getLogger().severe("Failed to load extension: " + file.getName());
+ e.printStackTrace();
+ iterator.remove();
+ }
+ }
+ }
+
+ if (!pendingFiles.isEmpty()) {
+ for (File file : pendingFiles) {
+ plugin.getLogger().severe("Could not load " + file.getName() + ": Dependency requirements not met.");
}
}
}
+ /**
+ * Represents the result of an individual extension load attempt.
+ */
+ private enum LoadResult {
+ /** Extension was loaded successfully. */
+ SUCCESS,
+ /** Extension failed to load due to missing data or errors. */
+ FAILED,
+ /** Extension is waiting for its dependencies to be loaded. */
+ WAITING
+ }
+
/**
* Disables a specific extension by its ID.
*
@@ -94,7 +147,6 @@ public boolean disableExtension(String id) {
plugin.getLogger().severe("Error disabling extension " + id);
e.printStackTrace();
} finally {
- // Remove from registry regardless of success/failure to ensure clean state
loadedInstances.remove(id);
loadedExtensionsInfo.remove(id);
}
@@ -102,97 +154,115 @@ public boolean disableExtension(String id) {
}
/**
- * Disables all loaded extensions and clears the registry.
+ * Disables all currently loaded extensions and clears the internal registries.
*/
public void disableAllExtensions() {
- // Create a copy of values to avoid ConcurrentModificationException during iteration
- for (IMCExtension extension : new HashMap<>(loadedInstances).values()) {
- try {
- extension.onDisable(plugin, executor);
- } catch (Exception e) {
- plugin.getLogger().severe("Error disabling extension " + extension.getId());
- e.printStackTrace();
- }
+ for (String id : new HashMap<>(loadedInstances).keySet()) {
+ disableExtension(id);
}
loadedExtensionsInfo.clear();
loadedInstances.clear();
}
/**
- * Loads a specific extension JAR file.
+ * Logic for loading a single extension JAR.
+ *
+ * This involves reading the nested extension.yml, verifying base plugin dependencies,
+ * checking for other required extensions, and performing reflective instantiation.
+ *
*
- * @param jarFile The JAR file to load.
- * @throws IOException If file access fails.
- * @throws ReflectiveOperationException If any reflection error occurs (ClassNotFound, NoSuchMethod, etc.).
+ * @param jarFile The JAR file to attempt to load.
+ * @return The {@link LoadResult} indicating success, failure, or a dependency-induced wait.
+ * @throws IOException If the file cannot be read.
+ * @throws ReflectiveOperationException If the main class cannot be found or instantiated.
*/
- private void loadExtension(File jarFile) throws IOException, ReflectiveOperationException {
- // 1. Setup ClassLoader (Parent is the plugin's loader so extension can see Bukkit/Plugin API)
- URL[] urls = {jarFile.toURI().toURL()};
- URLClassLoader loader = new URLClassLoader(urls, plugin.getClass().getClassLoader());
-
- // 2. Scan JAR to find the class implementing IMCExtension
- Class> mainClass = null;
+ private LoadResult loadExtension(File jarFile) throws IOException, ReflectiveOperationException {
+ String mainClassName = null;
+ String id = null;
+ String version = "1.0.0";
+
+ List baseDepend = new ArrayList<>();
+ List extDepend = new ArrayList<>();
+ // 1. Read extension.yml from the JAR
try (JarFile jar = new JarFile(jarFile)) {
- Enumeration entries = jar.entries();
-
- while (entries.hasMoreElements()) {
- JarEntry entry = entries.nextElement();
- if (entry.isDirectory() || !entry.getName().endsWith(".class")) continue;
-
- // Convert path to class name (e.g., com/example/Main.class -> com.example.Main)
- String className = entry.getName().replace('/', '.').replace(".class", "");
+ JarEntry entry = jar.getJarEntry("extension.yml");
+ if (entry == null) return LoadResult.FAILED;
- try {
- // Load the class strictly to check compatibility
- Class> clazz = loader.loadClass(className);
+ try (InputStream input = jar.getInputStream(entry)) {
+ Yaml yaml = new Yaml();
+ Map data = yaml.load(input);
+ if (data != null) {
+ id = (String) data.get("name");
+ mainClassName = (String) data.get("main");
+ version = String.valueOf(data.getOrDefault("version", "1.0.0"));
- // Enforce usage of IMCExtension interface
- if (IMCExtension.class.isAssignableFrom(clazz) && !clazz.isInterface()) {
- mainClass = clazz;
- break; // Found the entry point
+ // Navigate Nested Map for 'base'
+ if (data.get("base") instanceof Map) {
+ @SuppressWarnings("unchecked")
+ Map baseSection = (Map) data.get("base");
+ if (baseSection.get("depend") instanceof List) {
+ @SuppressWarnings("unchecked")
+ List castList = (List) baseSection.get("depend");
+ baseDepend = castList;
+ }
+ }
+
+ // Navigate Nested Map for 'extension'
+ if (data.get("extension") instanceof Map) {
+ @SuppressWarnings("unchecked")
+ Map extSection = (Map) data.get("extension");
+ if (extSection.get("depend") instanceof List) {
+ @SuppressWarnings("unchecked")
+ List castList = (List) extSection.get("depend");
+ extDepend = castList;
+ }
}
- } catch (ClassNotFoundException | NoClassDefFoundError ignored) {
- // Skip classes that fail to load (e.g., optional dependencies missing)
}
}
}
- if (mainClass == null) {
- plugin.getLogger().warning("Skipping " + jarFile.getName() + ": No class implementing IMCExtension found.");
- return;
- }
+ if (id == null || mainClassName == null) return LoadResult.FAILED;
- // 3. Instantiate the Extension
- // Updated to use Constructor.newInstance() instead of deprecated Class.newInstance()
- IMCExtension extension = (IMCExtension) mainClass.getDeclaredConstructor().newInstance();
- String id = extension.getId();
- String version = extension.getVersion();
+ // 2. Check Base Plugin Dependencies
+ for (String dep : baseDepend) {
+ if (!Bukkit.getPluginManager().isPluginEnabled(dep)) {
+ plugin.getLogger().warning("Skipping " + id + ": Missing base plugin '" + dep + "'");
+ return LoadResult.FAILED;
+ }
+ }
- // 4. Duplicate ID Check
- if (loadedExtensionsInfo.containsKey(id)) {
- plugin.getLogger().warning("Skipping " + jarFile.getName() + ": Duplicate Extension ID '" + id + "' already loaded.");
- return;
+ // 3. Check Extension Dependencies
+ for (String dep : extDepend) {
+ if (!loadedInstances.containsKey(dep)) return LoadResult.WAITING;
}
- // 5. Lifecycle: Load
+ // 4. Load Classes and Instantiate
+ URL[] urls = {jarFile.toURI().toURL()};
+ URLClassLoader loader = new URLClassLoader(urls, plugin.getClass().getClassLoader());
+ Class> clazz = loader.loadClass(mainClassName);
+
+ if (!IMCExtension.class.isAssignableFrom(clazz)) return LoadResult.FAILED;
+ if (loadedExtensionsInfo.containsKey(id)) return LoadResult.FAILED;
+
+ IMCExtension extension = (IMCExtension) clazz.getDeclaredConstructor().newInstance();
+
try {
extension.onLoad(plugin, executor);
-
- // 6. Register
loadedExtensionsInfo.put(id, version);
loadedInstances.put(id, extension);
-
plugin.getLogger().info("Loaded Extension: " + id + " (v" + version + ")");
+ return LoadResult.SUCCESS;
} catch (Exception e) {
- plugin.getLogger().severe("Error occurred while initializing extension: " + id);
e.printStackTrace();
+ return LoadResult.FAILED;
}
}
-
+
/**
- * Gets a map of loaded extension IDs and their versions.
- * * @return Map of {ID : Version}
+ * Gets a map of all currently loaded extension IDs and their versions.
+ *
+ * @return An unmodifiable-style copy of the loaded extensions info map.
*/
public Map getLoadedExtensions() {
return new HashMap<>(loadedExtensionsInfo);
diff --git a/src/main/resources/extension.yml b/src/main/resources/extension.yml
new file mode 100644
index 0000000..ac57855
--- /dev/null
+++ b/src/main/resources/extension.yml
@@ -0,0 +1,12 @@
+# Example
+name: MyEpicExtension
+version: 1.0.0
+main: io.github.example.EpicExtension
+
+base:
+ depend: [Vault, PlaceholderAPI]
+ softdepend: [Essentials]
+
+extension:
+ depend: [CoreExtension]
+ softdepend: [OptionalModule]