Mod dependency resolution improvements (#7972)

* soft dependencies + better mod resolution algorithm

* update ModMeta#toString

* var

* add #7962 bugfix

* Use existing code to resolve

* add state text for mod dialog

* bugfix

* fix error text

* remove external resolver class

It's simpler like that :)
This commit is contained in:
Phinner
2023-02-01 20:21:42 +00:00
committed by GitHub
parent 2dcab97b6d
commit 0d2dfadba7
3 changed files with 156 additions and 109 deletions

View File

@@ -150,12 +150,16 @@ mod.incompatiblemod = [red]Incompatible
mod.blacklisted = [red]Unsupported mod.blacklisted = [red]Unsupported
mod.unmetdependencies = [red]Unmet Dependencies mod.unmetdependencies = [red]Unmet Dependencies
mod.erroredcontent = [red]Content Errors 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.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.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.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.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.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} mod.requiresversion = Requires game version: [red]{0}

View File

@@ -43,6 +43,7 @@ public class Mods implements Loadable{
private int totalSprites; private int totalSprites;
private static ObjectFloatMap<String> textureResize = new ObjectFloatMap<>(); private static ObjectFloatMap<String> textureResize = new ObjectFloatMap<>();
private MultiPacker packer; private MultiPacker packer;
private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader()); private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader());
Seq<LoadedMod> mods = new Seq<>(); Seq<LoadedMod> mods = new Seq<>();
@@ -103,7 +104,7 @@ public class Mods implements Loadable{
file.copyTo(dest); file.copyTo(dest);
try{ try{
var loaded = loadMod(dest, true); var loaded = loadMod(dest, true, true);
mods.add(loaded); mods.add(loaded);
requiresReload = true; requiresReload = true;
//enable the mod on import //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.*/ /** Loads all mods from the folder, but does not call any methods on them.*/
public void load(){ public void load(){
var files = resolveDependencies(Seq.with(modDirectory.list()).filter(f -> var candidates = new Seq<Fi>();
f.extEquals("jar") || f.extEquals("zip") || (f.isDirectory() && (f.child("mod.json").exists() || f.child("mod.hjson").exists()))
)); // 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<String, Fi>();
var metas = new Seq<ModMeta>();
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); Log.debug("[Mods] Loading mod @", file);
try{ try{
LoadedMod mod = loadMod(file); LoadedMod mod = loadMod(file, false, entry.value == ModState.enabled);
mod.state = entry.value;
mods.add(mod); mods.add(mod);
if(steam) mod.addSteamID(file.name());
}catch(Throwable e){ }catch(Throwable e){
if(e instanceof ClassNotFoundException && e.getMessage().contains("mindustry.plugin.Plugin")){ 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()); 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{ }else{
Log.err("Failed to load mod file @. Skipping.", file); Log.err("Failed to load mod file @. Skipping.", file);
Log.err(e); Log.err(e);
@@ -438,21 +477,19 @@ public class Mods implements Loadable{
} }
} }
//load workshop mods now // Resolve the state
for(Fi file : resolveDependencies(platform.getWorkshopContent(LoadedMod.class))){ mods.each(this::updateDependencies);
try{ for(var mod : mods){
LoadedMod mod = loadMod(file); // Skip mods where the state has already been resolved
mods.add(mod); if(mod.state != ModState.enabled)continue;
mod.addSteamID(file.name()); if(!mod.isSupported()){
}catch(Throwable e){ mod.state = ModState.unsupported;
Log.err("Failed to load mod workshop file @. Skipping.", file); }else if(!mod.shouldBeEnabled()){
Log.err(e); mod.state = ModState.disabled;
} }
} }
resolveModState();
sortMods(); sortMods();
buildFiles(); 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))); 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){ private void updateDependencies(LoadedMod mod){
mod.dependencies.clear(); mod.dependencies.clear();
mod.missingDependencies.clear(); mod.missingDependencies.clear();
@@ -485,22 +510,10 @@ public class Mods implements Loadable{
} }
} }
private void topoSort(LoadedMod mod, Seq<LoadedMod> stack, ObjectSet<LoadedMod> 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. */ /** @return mods ordered in the correct way needed for dependencies. */
private Seq<LoadedMod> orderedMods(){ public Seq<LoadedMod> orderedMods(){
ObjectSet<LoadedMod> visited = new ObjectSet<>(); var mapping = mods.asMap(m -> m.meta.name);
Seq<LoadedMod> result = new Seq<>(); return resolveDependencies(mods.map(m -> m.meta)).orderedKeys().map(mapping::get);
eachEnabled(mod -> {
if(!visited.contains(mod)){
topoSort(mod, result, visited);
}
});
return result;
} }
public LoadedMod locateMod(String name){ public LoadedMod locateMod(String name){
@@ -758,12 +771,12 @@ public class Mods implements Loadable{
/** Iterates through each mod with a main class. */ /** Iterates through each mod with a main class. */
public void eachClass(Cons<Mod> cons){ public void eachClass(Cons<Mod> 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. */ /** Iterates through each enabled mod. */
public void eachEnabled(Cons<LoadedMod> cons){ public void eachEnabled(Cons<LoadedMod> cons){
mods.each(LoadedMod::enabled, cons); orderedMods().each(LoadedMod::enabled, cons);
} }
public void contextRun(LoadedMod mod, Runnable run){ public void contextRun(LoadedMod mod, Runnable run){
@@ -793,78 +806,71 @@ public class Mods implements Loadable{
return meta; return meta;
} }
/** Resolves the loading order of a list mods/plugins using their internal names. /** Resolves the loading order of a list mods/plugins using their internal names. */
* It also skips non-mods files or folders. */ public OrderedMap<String, ModState> resolveDependencies(Seq<ModMeta> metas){
public Seq<Fi> resolveDependencies(Seq<Fi> files){ var context = new ModResolutionContext();
ObjectMap<String, Fi> fileMapping = new ObjectMap<>();
ObjectMap<String, Seq<String>> dependencies = new ObjectMap<>();
for(Fi file : files){ for(var meta : metas){
ModMeta meta = null; Seq<ModDependency> dependencies = new Seq<>();
for(var dependency : meta.dependencies){
try{ dependencies.add(new ModDependency(dependency, true));
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 dependency : meta.softDependencies){
if(meta == null || meta.name == null) continue; dependencies.add(new ModDependency(dependency, false));
dependencies.put(meta.name, meta.dependencies);
fileMapping.put(meta.name, file);
}
ObjectSet<String> visited = new ObjectSet<>();
OrderedSet<String> 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();
} }
context.dependencies.put(meta.name, dependencies);
} }
// Adds the invalid mods for(var key : context.dependencies.keys()){
for(String missingMod : dependencies.keys()){ if (context.ordered.contains(key)) {
if(!ordered.contains(missingMod)) ordered.add(missingMod, 0); continue;
}
resolve(key, context);
context.visited.clear();
} }
Seq<Fi> resolved = ordered.orderedItems().map(fileMapping::get); var result = new OrderedMap<String, ModState>();
// Since the resolver explores the dependencies from leaves to the root, reverse the seq for(var name : context.ordered){
resolved.reverse(); result.put(name, ModState.enabled);
return resolved; }
result.putAll(context.invalid);
return result;
} }
/** Recursive search of dependencies */ private boolean resolve(String element, ModResolutionContext context){
public void resolveDependencies(String modName, ObjectMap<String, Seq<String>> dependencies, OrderedSet<String> ordered, ObjectSet<String> visited){ context.visited.add(element);
visited.add(modName); for(final var dependency : context.dependencies.get(element)){
// Circular dependencies ?
for(String dependency : dependencies.get(modName)){ if(context.visited.contains(dependency.name) && !context.ordered.contains(dependency.name)){
// Checks if the dependency tree isn't circular and that the dependency is not missing context.invalid.put(dependency.name, ModState.circularDependencies);
if(!visited.contains(dependency) && dependencies.containsKey(dependency)){ return false;
// Skips if the dependency was already explored in a separate tree // If dependency present, resolve it, or if it's not required, ignore it
if(ordered.contains(dependency)) continue; }else if(context.dependencies.containsKey(dependency.name)){
ordered.add(dependency); if(!context.ordered.contains(dependency.name) && !resolve(dependency.name, context) && dependency.required){
resolveDependencies(dependency, dependencies, ordered, visited); 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. /** Loads a mod file+meta, but does not add it to the list.
* Note that directories can be loaded as mods. */ * Note that directories can be loaded as mods. */
private LoadedMod loadMod(Fi sourceFile) throws Exception{ 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. /** Loads a mod file+meta, but does not add it to the list.
* Note that directories can be loaded as mods. */ * 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(); Time.mark();
ZipFi rootZip = null; ZipFi rootZip = null;
@@ -930,7 +936,8 @@ public class Mods implements Loadable{
!skipModLoading() && !skipModLoading() &&
Core.settings.getBool("mod-" + baseName + "-enabled", true) && Core.settings.getBool("mod-" + baseName + "-enabled", true) &&
Version.isAtLeast(meta.minGameVersion) && Version.isAtLeast(meta.minGameVersion) &&
(meta.getMinMajor() >= 136 || headless) (meta.getMinMajor() >= 136 || headless) &&
initialize
){ ){
if(ios){ if(ios){
throw new ModLoadException("Java class mods are not supported on 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 String name, minGameVersion = "0";
public @Nullable String displayName, author, description, subtitle, version, main, repo; public @Nullable String displayName, author, description, subtitle, version, main, repo;
public Seq<String> dependencies = Seq.with(); public Seq<String> dependencies = Seq.with();
public Seq<String> softDependencies = Seq.with();
/** Hidden mods are only server-side or client-side, and do not support adding new content. */ /** Hidden mods are only server-side or client-side, and do not support adding new content. */
public boolean hidden; public boolean hidden;
/** If true, this mod should be loaded as a Java class mod. This is technically optional, but highly recommended. */ /** If true, this mod should be loaded as a Java class mod. This is technically optional, but highly recommended. */
@@ -1189,15 +1197,23 @@ public class Mods implements Loadable{
@Override @Override
public String toString(){ public String toString(){
return "ModMeta{" + return "ModMeta{" +
"name='" + name + '\'' + "name='" + name + '\'' +
", author='" + author + '\'' + ", minGameVersion='" + minGameVersion + '\'' +
", version='" + version + '\'' + ", displayName='" + displayName + '\'' +
", main='" + main + '\'' + ", author='" + author + '\'' +
", minGameVersion='" + minGameVersion + '\'' + ", description='" + description + '\'' +
", hidden=" + hidden + ", subtitle='" + subtitle + '\'' +
", repo=" + repo + ", version='" + version + '\'' +
", texturescale=" + texturescale + ", 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, enabled,
contentErrors, contentErrors,
missingDependencies, missingDependencies,
incompleteDependencies,
circularDependencies,
unsupported, unsupported,
disabled, disabled,
} }
public static class ModResolutionContext {
public final ObjectMap<String, Seq<ModDependency>> dependencies = new ObjectMap<>();
public final ObjectSet<String> visited = new ObjectSet<>();
public final OrderedSet<String> ordered = new OrderedSet<>();
public final ObjectMap<String, ModState> 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;
}
}
} }

View File

@@ -329,6 +329,10 @@ public class ModsDialog extends BaseDialog{
return "@mod.blacklisted"; return "@mod.blacklisted";
}else if(!item.isSupported()){ }else if(!item.isSupported()){
return "@mod.incompatiblegame"; 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()){ }else if(item.hasUnmetDependencies()){
return "@mod.unmetdependencies"; return "@mod.unmetdependencies";
}else if(item.hasContentErrors()){ }else if(item.hasContentErrors()){
@@ -346,6 +350,10 @@ public class ModsDialog extends BaseDialog{
return "@mod.blacklisted.details"; return "@mod.blacklisted.details";
}else if(!item.isSupported()){ }else if(!item.isSupported()){
return Core.bundle.format("mod.requiresversion.details", item.meta.minGameVersion); 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()){ }else if(item.hasUnmetDependencies()){
return Core.bundle.format("mod.missingdependencies.details", item.missingDependencies.toString(", ")); return Core.bundle.format("mod.missingdependencies.details", item.missingDependencies.toString(", "));
}else if(item.hasContentErrors()){ }else if(item.hasContentErrors()){