diff --git a/core/assets/bundles/bundle.properties b/core/assets/bundles/bundle.properties index c8f06241a5..5c66b03c4f 100644 --- a/core/assets/bundles/bundle.properties +++ b/core/assets/bundles/bundle.properties @@ -150,12 +150,16 @@ mod.incompatiblemod = [red]Incompatible mod.blacklisted = [red]Unsupported mod.unmetdependencies = [red]Unmet Dependencies mod.erroredcontent = [red]Content Errors +mod.circulardependencies = [red]Circular Dependencies +mod.incompletedependencies = [red]Incomplete Dependencies mod.requiresversion.details = Requires game version: [accent]{0}[]\nYour game is outdated. This mod requires a newer version of the game (possibly a beta/alpha release) to function. mod.outdatedv7.details = This mod is incompatible with the latest version of the game. The author must update it, and add [accent]minGameVersion: 136[] to its [accent]mod.json[] file. mod.blacklisted.details = This mod has been manually blacklisted for causing crashes or other issues with this version of the game. Do not use it. mod.missingdependencies.details = This mod is missing dependencies: {0} mod.erroredcontent.details = This mod caused errors when loading. Ask the mod author to fix them. +mod.circulardependencies.details = This mod has dependencies that depends on each other. +mod.incompletedependencies.details = This mod is unable to be loaded due to invalid or missing dependencies: {0}. mod.requiresversion = Requires game version: [red]{0} diff --git a/core/src/mindustry/mod/Mods.java b/core/src/mindustry/mod/Mods.java index 8d50f631ad..be81eacba8 100644 --- a/core/src/mindustry/mod/Mods.java +++ b/core/src/mindustry/mod/Mods.java @@ -43,6 +43,7 @@ public class Mods implements Loadable{ private int totalSprites; private static ObjectFloatMap textureResize = new ObjectFloatMap<>(); private MultiPacker packer; + private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader()); Seq mods = new Seq<>(); @@ -103,7 +104,7 @@ public class Mods implements Loadable{ file.copyTo(dest); try{ - var loaded = loadMod(dest, true); + var loaded = loadMod(dest, true, true); mods.add(loaded); requiresReload = true; //enable the mod on import @@ -418,19 +419,57 @@ public class Mods implements Loadable{ /** Loads all mods from the folder, but does not call any methods on them.*/ public void load(){ - var files = resolveDependencies(Seq.with(modDirectory.list()).filter(f -> - f.extEquals("jar") || f.extEquals("zip") || (f.isDirectory() && (f.child("mod.json").exists() || f.child("mod.hjson").exists())) - )); + var candidates = new Seq(); + + // Add local mods + Seq.with(modDirectory.list()) + .filter(f -> f.extEquals("jar") || f.extEquals("zip") || (f.isDirectory() && (f.child("mod.json").exists() || f.child("mod.hjson").exists()))) + .each(candidates::add); + + // Add Steam workshop mods + platform.getWorkshopContent(LoadedMod.class) + .each(candidates::add); + + var mapping = new ObjectMap(); + var metas = new Seq(); + + for(Fi file : candidates){ + ModMeta meta = null; + + try{ + Fi zip = file.isDirectory() ? file : new ZipFi(file); + + if(zip.list().length == 1 && zip.list()[0].isDirectory()){ + zip = zip.list()[0]; + } + + meta = findMeta(zip); + }catch(Throwable ignored){ + } + + if(meta == null || meta.name == null) continue; + metas.add(meta); + mapping.put(meta.name, file); + } + + var resolved = resolveDependencies(metas); + for(var entry : resolved){ + var file = mapping.get(entry.key); + var steam = platform.getWorkshopContent(LoadedMod.class).contains(file); - for(Fi file : files){ Log.debug("[Mods] Loading mod @", file); try{ - LoadedMod mod = loadMod(file); + LoadedMod mod = loadMod(file, false, entry.value == ModState.enabled); + mod.state = entry.value; mods.add(mod); + if(steam) mod.addSteamID(file.name()); }catch(Throwable e){ if(e instanceof ClassNotFoundException && e.getMessage().contains("mindustry.plugin.Plugin")){ Log.info("Plugin '@' is outdated and needs to be ported to 6.0! Update its main class to inherit from 'mindustry.mod.Plugin'. See https://mindustrygame.github.io/wiki/modding/6-migrationv6/", file.name()); + }else if(steam){ + Log.err("Failed to load mod workshop file @. Skipping.", file); + Log.err(e); }else{ Log.err("Failed to load mod file @. Skipping.", file); Log.err(e); @@ -438,21 +477,19 @@ public class Mods implements Loadable{ } } - //load workshop mods now - for(Fi file : resolveDependencies(platform.getWorkshopContent(LoadedMod.class))){ - try{ - LoadedMod mod = loadMod(file); - mods.add(mod); - mod.addSteamID(file.name()); - }catch(Throwable e){ - Log.err("Failed to load mod workshop file @. Skipping.", file); - Log.err(e); + // Resolve the state + mods.each(this::updateDependencies); + for(var mod : mods){ + // Skip mods where the state has already been resolved + if(mod.state != ModState.enabled)continue; + if(!mod.isSupported()){ + mod.state = ModState.unsupported; + }else if(!mod.shouldBeEnabled()){ + mod.state = ModState.disabled; } } - resolveModState(); sortMods(); - buildFiles(); } @@ -461,18 +498,6 @@ public class Mods implements Loadable{ mods.sort(Structs.comps(Structs.comparingInt(m -> m.state.ordinal()), Structs.comparing(m -> m.name))); } - private void resolveModState(){ - mods.each(this::updateDependencies); - - for(LoadedMod mod : mods){ - mod.state = - !mod.isSupported() ? ModState.unsupported : - mod.hasUnmetDependencies() ? ModState.missingDependencies : - !mod.shouldBeEnabled() ? ModState.disabled : - ModState.enabled; - } - } - private void updateDependencies(LoadedMod mod){ mod.dependencies.clear(); mod.missingDependencies.clear(); @@ -485,22 +510,10 @@ public class Mods implements Loadable{ } } - private void topoSort(LoadedMod mod, Seq stack, ObjectSet visited){ - visited.add(mod); - mod.dependencies.each(m -> !visited.contains(m), m -> topoSort(m, stack, visited)); - stack.add(mod); - } - /** @return mods ordered in the correct way needed for dependencies. */ - private Seq orderedMods(){ - ObjectSet visited = new ObjectSet<>(); - Seq result = new Seq<>(); - eachEnabled(mod -> { - if(!visited.contains(mod)){ - topoSort(mod, result, visited); - } - }); - return result; + public Seq orderedMods(){ + var mapping = mods.asMap(m -> m.meta.name); + return resolveDependencies(mods.map(m -> m.meta)).orderedKeys().map(mapping::get); } public LoadedMod locateMod(String name){ @@ -758,12 +771,12 @@ public class Mods implements Loadable{ /** Iterates through each mod with a main class. */ public void eachClass(Cons cons){ - mods.each(p -> p.main != null, p -> contextRun(p, () -> cons.get(p.main))); + orderedMods().each(p -> p.main != null, p -> contextRun(p, () -> cons.get(p.main))); } /** Iterates through each enabled mod. */ public void eachEnabled(Cons cons){ - mods.each(LoadedMod::enabled, cons); + orderedMods().each(LoadedMod::enabled, cons); } public void contextRun(LoadedMod mod, Runnable run){ @@ -793,78 +806,71 @@ public class Mods implements Loadable{ return meta; } - /** Resolves the loading order of a list mods/plugins using their internal names. - * It also skips non-mods files or folders. */ - public Seq resolveDependencies(Seq files){ - ObjectMap fileMapping = new ObjectMap<>(); - ObjectMap> dependencies = new ObjectMap<>(); + /** Resolves the loading order of a list mods/plugins using their internal names. */ + public OrderedMap resolveDependencies(Seq metas){ + var context = new ModResolutionContext(); - for(Fi file : files){ - ModMeta meta = null; - - try{ - Fi zip = file.isDirectory() ? file : new ZipFi(file); - - if(zip.list().length == 1 && zip.list()[0].isDirectory()){ - zip = zip.list()[0]; - } - - meta = findMeta(zip); - }catch(Throwable ignored){ + for(var meta : metas){ + Seq dependencies = new Seq<>(); + for(var dependency : meta.dependencies){ + dependencies.add(new ModDependency(dependency, true)); } - - if(meta == null || meta.name == null) continue; - dependencies.put(meta.name, meta.dependencies); - fileMapping.put(meta.name, file); - } - - ObjectSet visited = new ObjectSet<>(); - OrderedSet ordered = new OrderedSet<>(); - - for(String modName : dependencies.keys()){ - if(!ordered.contains(modName)){ - // Adds the loaded mods at the beginning of the list - ordered.add(modName, 0); - resolveDependencies(modName, dependencies, ordered, visited); - visited.clear(); + for(var dependency : meta.softDependencies){ + dependencies.add(new ModDependency(dependency, false)); } + context.dependencies.put(meta.name, dependencies); } - // Adds the invalid mods - for(String missingMod : dependencies.keys()){ - if(!ordered.contains(missingMod)) ordered.add(missingMod, 0); + for(var key : context.dependencies.keys()){ + if (context.ordered.contains(key)) { + continue; + } + resolve(key, context); + context.visited.clear(); } - Seq resolved = ordered.orderedItems().map(fileMapping::get); - // Since the resolver explores the dependencies from leaves to the root, reverse the seq - resolved.reverse(); - return resolved; + var result = new OrderedMap(); + for(var name : context.ordered){ + result.put(name, ModState.enabled); + } + result.putAll(context.invalid); + return result; } - /** Recursive search of dependencies */ - public void resolveDependencies(String modName, ObjectMap> dependencies, OrderedSet ordered, ObjectSet visited){ - visited.add(modName); - - for(String dependency : dependencies.get(modName)){ - // Checks if the dependency tree isn't circular and that the dependency is not missing - if(!visited.contains(dependency) && dependencies.containsKey(dependency)){ - // Skips if the dependency was already explored in a separate tree - if(ordered.contains(dependency)) continue; - ordered.add(dependency); - resolveDependencies(dependency, dependencies, ordered, visited); + private boolean resolve(String element, ModResolutionContext context){ + context.visited.add(element); + for(final var dependency : context.dependencies.get(element)){ + // Circular dependencies ? + if(context.visited.contains(dependency.name) && !context.ordered.contains(dependency.name)){ + context.invalid.put(dependency.name, ModState.circularDependencies); + return false; + // If dependency present, resolve it, or if it's not required, ignore it + }else if(context.dependencies.containsKey(dependency.name)){ + if(!context.ordered.contains(dependency.name) && !resolve(dependency.name, context) && dependency.required){ + context.invalid.put(element, ModState.incompleteDependencies); + return false; + } + // The dependency is missing, but if not required, skip + }else if(dependency.required){ + context.invalid.put(element, ModState.missingDependencies); + return false; } } + if(!context.ordered.contains(element)){ + context.ordered.add(element); + } + return true; } /** Loads a mod file+meta, but does not add it to the list. * Note that directories can be loaded as mods. */ private LoadedMod loadMod(Fi sourceFile) throws Exception{ - return loadMod(sourceFile, false); + return loadMod(sourceFile, false, true); } /** Loads a mod file+meta, but does not add it to the list. * Note that directories can be loaded as mods. */ - private LoadedMod loadMod(Fi sourceFile, boolean overwrite) throws Exception{ + private LoadedMod loadMod(Fi sourceFile, boolean overwrite, boolean initialize) throws Exception{ Time.mark(); ZipFi rootZip = null; @@ -930,7 +936,8 @@ public class Mods implements Loadable{ !skipModLoading() && Core.settings.getBool("mod-" + baseName + "-enabled", true) && Version.isAtLeast(meta.minGameVersion) && - (meta.getMinMajor() >= 136 || headless) + (meta.getMinMajor() >= 136 || headless) && + initialize ){ if(ios){ throw new ModLoadException("Java class mods are not supported on iOS."); @@ -1152,6 +1159,7 @@ public class Mods implements Loadable{ public String name, minGameVersion = "0"; public @Nullable String displayName, author, description, subtitle, version, main, repo; public Seq dependencies = Seq.with(); + public Seq softDependencies = Seq.with(); /** Hidden mods are only server-side or client-side, and do not support adding new content. */ public boolean hidden; /** If true, this mod should be loaded as a Java class mod. This is technically optional, but highly recommended. */ @@ -1185,19 +1193,27 @@ public class Mods implements Loadable{ int dot = ver.indexOf("."); return dot != -1 ? Strings.parseInt(ver.substring(0, dot), 0) : Strings.parseInt(ver, 0); } - + @Override public String toString(){ return "ModMeta{" + - "name='" + name + '\'' + - ", author='" + author + '\'' + - ", version='" + version + '\'' + - ", main='" + main + '\'' + - ", minGameVersion='" + minGameVersion + '\'' + - ", hidden=" + hidden + - ", repo=" + repo + - ", texturescale=" + texturescale + - '}'; + "name='" + name + '\'' + + ", minGameVersion='" + minGameVersion + '\'' + + ", displayName='" + displayName + '\'' + + ", author='" + author + '\'' + + ", description='" + description + '\'' + + ", subtitle='" + subtitle + '\'' + + ", version='" + version + '\'' + + ", main='" + main + '\'' + + ", repo='" + repo + '\'' + + ", dependencies=" + dependencies + + ", softDependencies=" + softDependencies + + ", hidden=" + hidden + + ", java=" + java + + ", keepOutlines=" + keepOutlines + + ", texturescale=" + texturescale + + ", pregenerated=" + pregenerated + + '}'; } } @@ -1211,7 +1227,26 @@ public class Mods implements Loadable{ enabled, contentErrors, missingDependencies, + incompleteDependencies, + circularDependencies, unsupported, disabled, } + + public static class ModResolutionContext { + public final ObjectMap> dependencies = new ObjectMap<>(); + public final ObjectSet visited = new ObjectSet<>(); + public final OrderedSet ordered = new OrderedSet<>(); + public final ObjectMap invalid = new OrderedMap<>(); + } + + public static final class ModDependency{ + public final String name; + public final boolean required; + + public ModDependency(String name, boolean required){ + this.name = name; + this.required = required; + } + } } diff --git a/core/src/mindustry/ui/dialogs/ModsDialog.java b/core/src/mindustry/ui/dialogs/ModsDialog.java index f692da9d02..f4b992bf1a 100644 --- a/core/src/mindustry/ui/dialogs/ModsDialog.java +++ b/core/src/mindustry/ui/dialogs/ModsDialog.java @@ -329,6 +329,10 @@ public class ModsDialog extends BaseDialog{ return "@mod.blacklisted"; }else if(!item.isSupported()){ return "@mod.incompatiblegame"; + }else if(item.state == ModState.circularDependencies){ + return "@mod.circulardependencies"; + }else if(item.state == ModState.incompleteDependencies){ + return "@mod.incompletedependencies"; }else if(item.hasUnmetDependencies()){ return "@mod.unmetdependencies"; }else if(item.hasContentErrors()){ @@ -346,6 +350,10 @@ public class ModsDialog extends BaseDialog{ return "@mod.blacklisted.details"; }else if(!item.isSupported()){ return Core.bundle.format("mod.requiresversion.details", item.meta.minGameVersion); + }else if(item.state == ModState.circularDependencies){ + return "@mod.circulardependencies.details"; + }else if(item.state == ModState.incompleteDependencies){ + return Core.bundle.format("mod.incompletedependencies.details", item.missingDependencies.toString(", ")); }else if(item.hasUnmetDependencies()){ return Core.bundle.format("mod.missingdependencies.details", item.missingDependencies.toString(", ")); }else if(item.hasContentErrors()){