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:
@@ -43,6 +43,7 @@ public class Mods implements Loadable{
|
||||
private int totalSprites;
|
||||
private static ObjectFloatMap<String> textureResize = new ObjectFloatMap<>();
|
||||
private MultiPacker packer;
|
||||
|
||||
private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader());
|
||||
|
||||
Seq<LoadedMod> 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<Fi>();
|
||||
|
||||
// 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);
|
||||
|
||||
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<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. */
|
||||
private Seq<LoadedMod> orderedMods(){
|
||||
ObjectSet<LoadedMod> visited = new ObjectSet<>();
|
||||
Seq<LoadedMod> result = new Seq<>();
|
||||
eachEnabled(mod -> {
|
||||
if(!visited.contains(mod)){
|
||||
topoSort(mod, result, visited);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
public Seq<LoadedMod> 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<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. */
|
||||
public void eachEnabled(Cons<LoadedMod> 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<Fi> resolveDependencies(Seq<Fi> files){
|
||||
ObjectMap<String, Fi> fileMapping = new ObjectMap<>();
|
||||
ObjectMap<String, Seq<String>> dependencies = new ObjectMap<>();
|
||||
/** Resolves the loading order of a list mods/plugins using their internal names. */
|
||||
public OrderedMap<String, ModState> resolveDependencies(Seq<ModMeta> 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<ModDependency> 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<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();
|
||||
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<Fi> 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<String, ModState>();
|
||||
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<String, Seq<String>> dependencies, OrderedSet<String> ordered, ObjectSet<String> 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<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. */
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()){
|
||||
|
||||
Reference in New Issue
Block a user