/* * Copyright (c) 2015 NOVA, All rights reserved. * This library is free software, licensed under GNU Lesser General Public License version 3 * * This file is part of NOVA. * * NOVA is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NOVA is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NOVA. If not, see <http://www.gnu.org/licenses/>. */ package nova.internal.core.launch; import nova.core.deps.Dependencies; import nova.core.deps.Dependency; import nova.core.deps.MavenDependency; import nova.core.loader.Mod; import nova.core.util.ProgressBar; import nova.internal.core.Game; import nova.internal.core.bootstrap.DependencyInjectionEntryPoint; import nova.internal.core.util.TopologicalSort; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; /** * The main class that launches NOVA mods. * * Correct order to call the methods is this: * <ol> * <li>{@link #generateDependencies()}</li> * <li>{@link #load()}</li> * </ol> * @author Calclavia, Kubuxu */ public class NovaLauncher extends ModLoader<Mod> { private static Optional<NovaLauncher> INSTANCE = Optional.empty(); private Map<Mod, List<MavenDependency>> neededDeps; private Map<Mod, Set<String[]>> missingDeps; private Map<Mod, Set<String[][]>> mismatchedDeps; private Map<String, Set<Mod>> duplicatedIDs; private boolean loadingErrored = false; public static Optional<NovaLauncher> instance() { return INSTANCE; } /** * Creates NovaLauncher. * @param modClasses mods to instantialize. * @param diep is required as we are installing additional modules to it. */ public NovaLauncher(DependencyInjectionEntryPoint diep, Set<Class<?>> modClasses) { super(Mod.class, diep, modClasses); INSTANCE = Optional.of(this); /** * Install all DI modules */ javaClasses.keySet().stream() .flatMap(mod -> Arrays.stream(mod.modules())) .forEach(diep::install); } @Override public void load() { this.load(new ProgressBar.NullProgressBar()); } @Override public void load(ProgressBar progressBar) { this.load(progressBar, true); } @Override public void load(ProgressBar progressBar, boolean finish) { super.load(progressBar, false); TopologicalSort.DirectedGraph<Mod> modGraph = new TopologicalSort.DirectedGraph<>(); mods.keySet().forEach(modGraph::addNode); missingDeps = new HashMap<>(); mismatchedDeps = new HashMap<>(); duplicatedIDs = new HashMap<>(); // Ensure we don't try to load two mods with the same ID. mods.keySet().forEach( mod -> { Class<?> modClass = getModClasses().getOrDefault(mod, getScalaClassesMap().get(mod)); if (mods.keySet().stream().filter(m -> m != mod && getModClasses().getOrDefault(m, getScalaClassesMap().get(m)) != modClass) .anyMatch(m -> mod.id().equals(m.id()))) { Game.logger().error("Found duplicate mod id '" + mod.id() + '@' + mod.version() + "' (" + mod.name() + "), class: " + modClass.getCanonicalName()); Set<Mod> duplicatedIDs = this.duplicatedIDs.get(mod.id()); if (duplicatedIDs == null) { duplicatedIDs = new HashSet<>(); this.duplicatedIDs.put(mod.id(), duplicatedIDs); } duplicatedIDs.add(mod); } } ); if (!duplicatedIDs.isEmpty()) { throw new InitializationException("Mods with duplicate IDs"); } // Create directed graph edges. mods.keySet().forEach( mod -> { Map<String, String> depMap = dependencyToMap(mod.dependencies()); depMap.forEach((id, version) -> { Optional<Mod> dependent = mods.keySet() .stream() .filter(m2 -> m2.id().equals(id)) .findFirst(); final boolean forced = version.endsWith("f") || version.endsWith("F"); if (forced) version = version.substring(0, version.length() - 1); // TODO: Compare NOVA version requirements. (can be done with a Semantic Versioning APO) if (dependent.isPresent()) { try { if (!versionMatches(version, dependent.get().version())) { // TODO: Display mod version patterns in a more user friendly manner. Game.logger().error("Mod '" + mod.id() + '@' + mod.version() + "' (" + mod.name() + ") needs mod '" + dependent.get().id() + '@' + dependent.get().version() + "' (" + dependent.get().name() + ") with version " + version); Set<String[][]> mismatchedDeps = this.mismatchedDeps.get(mod); if (mismatchedDeps == null) { mismatchedDeps = new HashSet<>(); this.mismatchedDeps.put(mod, mismatchedDeps); } mismatchedDeps.add(new String[][]{{dependent.get().id(), dependent.get().version()}, {id, version}}); } } catch (Exception ex) { Game.logger().error(ex.getMessage(), ex); loadingErrored = true; } modGraph.addEdge(dependent.get(), mod); } else if (forced) { // TODO: Display mod version patterns in a more user friendly manner. Game.logger().error("Mod '" + mod.id() + '@' + mod.version() + "' (" + mod.name() + ") needs mod '" + id + (version.isEmpty() ? '\'' : '@' + version + '\'')); Set<String[]> missingDeps = this.missingDeps.get(mod); if (missingDeps == null) { missingDeps = new HashSet<>(); this.missingDeps.put(mod, missingDeps); } missingDeps.add(new String[]{id, version}); } }); //Priority check mods.keySet().forEach( compareMod -> { if (mod.priority() < compareMod.priority()) { modGraph.addEdge(compareMod, mod); } }); } ); if (loadingErrored || !duplicatedIDs.isEmpty() || !missingDeps.isEmpty() || !mismatchedDeps.isEmpty()) { throw new InitializationException("Errors during mod loading."); } orderedMods.clear(); TopologicalSort.topologicalSort(modGraph) .stream() .map(mods::get) .forEachOrdered(orderedMods::add); Game.logger().info("NOVA mods loaded: " + mods.size()); if (finish) progressBar.finish(); } public Map<String, String> dependencyToMap(String[] dependencies) { return Arrays.stream(dependencies) .map(s -> { if (s.contains("@")) { String[] ret = new String[2]; ret[0] = s.substring(0, s.lastIndexOf('@')); ret[1] = s.substring(s.lastIndexOf('@') + 1); return ret; } else { return new String[]{s}; } }) .collect(Collectors.toMap(s -> s[0], s -> s.length > 1 ? s[1] : "")); } public Map<Mod, List<MavenDependency>> getNeededDeps() { if (neededDeps == null) { throw new IllegalStateException("Dependencies have not been generated"); } return neededDeps; } /** * Gets the mods with duplicate IDs. * * @return The mods with duplicate IDs. */ public Map<String, Set<Mod>> getDuplicateIDs() { return Collections.unmodifiableMap(this.duplicatedIDs.entrySet().stream() .collect(Collectors.toMap(e -> e.getKey(), e -> Collections.unmodifiableSet(e.getValue())))); } /** * Gets the mods with missing required dependencies. * * The String array is formatted as such: * <code>{id, version}</code> * * @return The mods with missing required dependencies. */ public Map<Mod, Set<String[]>> getMissingDeps() { return Collections.unmodifiableMap(this.missingDeps.entrySet().stream() .collect(Collectors.toMap(e -> e.getKey(), e -> Collections.unmodifiableSet(e.getValue())))); } /** * Gets the mods with mismatched dependencies. * * The 2D String array is formatted as such: * <code>{{id, actualVersion}, {id, expectedVersion}}</code> * * @return The mods with mismatched dependencies. */ public Map<Mod, Set<String[][]>> getMismatchedDeps() { return Collections.unmodifiableMap(this.mismatchedDeps.entrySet().stream() .collect(Collectors.toMap(e -> e.getKey(), e -> Collections.unmodifiableSet(e.getValue())))); } /** * Get the dependencies. Separated from preInit due to issues with ordering in case mods need to download mods before the preInit method is called. * The wrapper just needs to call this method right before it downloads the dependencies. */ public void generateDependencies() { neededDeps = new HashMap<>(); // This should be cleaned every time this method is run. Stream.concat(javaClasses.values().stream(), scalaClasses.values().stream()) .forEach(this::generateAndAddDependencies); } private void generateAndAddDependencies(Class<?> mod) { List<MavenDependency> deps; if (mod.isAnnotationPresent(Dependency.class)) { Dependency dependency = mod.getAnnotation(Dependency.class); deps = Collections.singletonList(new MavenDependency(dependency)); } else if (mod.isAnnotationPresent(Dependencies.class)) { Dependency[] dependencies = mod.getAnnotation(Dependencies.class).value(); deps = Arrays.stream(dependencies).map(MavenDependency::new).collect(Collectors.toList()); } else { return; } neededDeps.put(mod.getAnnotation(Mod.class), deps); } private static boolean versionMatches(String versionPattern, String version) { String[] versionPatternSplit = versionPattern.split("\\d"); long[] v = Arrays.stream(version.split("\\.")).mapToLong(Long::parseUnsignedLong).toArray(); int cutTo = 0; for (int i = 0; i < versionPatternSplit.length; i++) if (!"x".equalsIgnoreCase(versionPatternSplit[i])) cutTo = i; if (cutTo == 0) return true; if (cutTo > v.length) return false; String[] m = Arrays.copyOf(versionPatternSplit, cutTo); for (int i = 0; i < m.length; i++) { String pattern = m[i]; long l = v[i]; if ("x".equalsIgnoreCase(pattern)) continue; try { long k = Long.parseUnsignedLong(pattern); if (Long.compareUnsigned(l, k) < 0) return false; else continue; } catch (NumberFormatException e) { } if (VERSION_RANGE.matcher(pattern).matches()) { OptionalLong[] data = rangeToInts(pattern); OptionalLong min = data[0]; OptionalLong max = data[1]; if (!min.isPresent() && !max.isPresent()) throw new IllegalArgumentException(pattern); if (min.isPresent() && Long.compareUnsigned(l, min.getAsLong()) < 0) { return false; } if (max.isPresent() && Long.compareUnsigned(l, max.getAsLong()) > 0) { return false; } continue; } return false; } return true; } private static OptionalLong[] rangeToInts(String string) { Matcher m = VERSION_RANGE.matcher(string); if (m.matches()) { return new OptionalLong[]{ m.group(1).isEmpty() ? OptionalLong.empty() : OptionalLong.of(Long.parseUnsignedLong(m.group(1))), m.group(2).isEmpty() ? OptionalLong.empty() : OptionalLong.of(Long.parseUnsignedLong(m.group(2))) }; } else { throw new IllegalArgumentException(string); } } private static final Pattern VERSION_RANGE = Pattern.compile("^\\{(\\d*)-(\\d*)\\}$", Pattern.CASE_INSENSITIVE); }