/* * Copyright 2014 MovingBlocks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.terasology.rendering.nui.layers.mainMenu; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.terasology.assets.ResourceUrn; import org.terasology.config.Config; import org.terasology.config.ModuleConfig; import org.terasology.engine.SimpleUri; import org.terasology.engine.TerasologyConstants; import org.terasology.engine.module.ModuleManager; import org.terasology.engine.module.RemoteModuleExtension; import org.terasology.engine.paths.PathManager; import org.terasology.i18n.TranslationSystem; import org.terasology.math.geom.Vector2i; import org.terasology.module.DependencyInfo; import org.terasology.module.DependencyResolver; import org.terasology.module.Module; import org.terasology.module.ModuleLoader; import org.terasology.module.ModuleMetadata; import org.terasology.module.ResolutionResult; import org.terasology.naming.Name; import org.terasology.registry.In; import org.terasology.rendering.nui.Canvas; import org.terasology.rendering.nui.CoreScreenLayer; import org.terasology.rendering.nui.WidgetUtil; import org.terasology.rendering.nui.animation.MenuAnimationSystems; import org.terasology.rendering.nui.databinding.Binding; import org.terasology.rendering.nui.databinding.ReadOnlyBinding; import org.terasology.rendering.nui.itemRendering.AbstractItemRenderer; import org.terasology.rendering.nui.widgets.ResettableUIText; import org.terasology.rendering.nui.widgets.TextChangeEventListener; import org.terasology.rendering.nui.widgets.UIButton; import org.terasology.rendering.nui.widgets.UILabel; import org.terasology.rendering.nui.widgets.UIList; import org.terasology.world.generator.internal.WorldGeneratorManager; import java.io.IOException; import java.net.URL; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.stream.Collectors; /** */ public class SelectModulesScreen extends CoreScreenLayer { public static final ResourceUrn ASSET_URI = new ResourceUrn("engine:selectModsScreen"); private static final Logger logger = LoggerFactory.getLogger(SelectModulesScreen.class); @In private ModuleManager moduleManager; @In private Config config; @In private WorldGeneratorManager worldGenManager; @In private TranslationSystem translationSystem; private Map<Name, ModuleSelectionInfo> modulesLookup; private List<ModuleSelectionInfo> sortedModules; private List<ModuleSelectionInfo> allSortedModules; private DependencyResolver resolver; private ModuleListDownloader metaDownloader; private boolean needsUpdate = true; private final Comparator<? super ModuleSelectionInfo> moduleInfoComparator = (o1, o2) -> o1.getMetadata().getDisplayName().toString().compareTo( o2.getMetadata().getDisplayName().toString()); @Override public void onOpened() { super.onOpened(); for (ModuleSelectionInfo info : sortedModules) { info.setExplicitSelection(config.getDefaultModSelection().hasModule(info.getMetadata().getId())); } refreshSelection(); } @Override public void initialise() { setAnimationSystem(MenuAnimationSystems.createDefaultSwipeAnimation()); metaDownloader = new ModuleListDownloader(config.getNetwork().getMasterServer()); resolver = new DependencyResolver(moduleManager.getRegistry()); modulesLookup = Maps.newHashMap(); sortedModules = Lists.newArrayList(); for (Name moduleId : moduleManager.getRegistry().getModuleIds()) { Module latestVersion = moduleManager.getRegistry().getLatestModuleVersion(moduleId); if (!latestVersion.isOnClasspath()) { ModuleSelectionInfo info = ModuleSelectionInfo.local(latestVersion); modulesLookup.put(info.getMetadata().getId(), info); sortedModules.add(info); } } Collections.sort(sortedModules, moduleInfoComparator); allSortedModules = new ArrayList<>(sortedModules); final UIList<ModuleSelectionInfo> moduleList = find("moduleList", UIList.class); if (moduleList != null) { moduleList.setList(sortedModules); moduleList.setItemRenderer(new AbstractItemRenderer<ModuleSelectionInfo>() { public String getString(ModuleSelectionInfo value) { return value.getMetadata().getDisplayName().toString(); } @Override public void draw(ModuleSelectionInfo value, Canvas canvas) { if (isSelectedGameplayModule(value) && value.isValidToSelect()) { canvas.setMode("gameplay"); } else if (value.isSelected() && value.isExplicitSelection()) { canvas.setMode("enabled"); } else if (value.isSelected()) { canvas.setMode("dependency"); } else if (!value.isPresent()) { canvas.setMode("disabled"); } else if (!value.isValidToSelect()) { canvas.setMode("invalid"); } else { canvas.setMode("available"); } canvas.drawText(getString(value), canvas.getRegion()); } @Override public Vector2i getPreferredSize(ModuleSelectionInfo value, Canvas canvas) { String text = getString(value); return new Vector2i(canvas.getCurrentStyle().getFont().getWidth(text), canvas.getCurrentStyle().getFont().getLineHeight()); } }); //ItemActivateEventListener is triggered by double clicking moduleList.subscribe((widget, item) -> { if (item.isSelected() && moduleList.getSelection().isExplicitSelection()) { deselect(item); } else if (item.isValidToSelect()) { select(item); } }); ResettableUIText moduleSearch = find("moduleSearch", ResettableUIText.class); if (moduleSearch != null) { moduleSearch.subscribe(new TextChangeEventListener() { @Override public void onTextChange(String oldText, String newText) { sortedModules.clear(); for (ModuleSelectionInfo m : allSortedModules) { if (m.getMetadata().getDisplayName().toString().toLowerCase().contains(newText.toLowerCase())) { sortedModules.add(m); } } } }); } final Binding<ModuleMetadata> moduleInfoBinding = new ReadOnlyBinding<ModuleMetadata>() { @Override public ModuleMetadata get() { if (moduleList.getSelection() != null) { return moduleList.getSelection().getMetadata(); } return null; } }; UILabel name = find("name", UILabel.class); if (name != null) { name.bindText(new ReadOnlyBinding<String>() { @Override public String get() { if (moduleInfoBinding.get() != null) { return moduleInfoBinding.get().getDisplayName().toString(); } return ""; } }); } UILabel installedVersion = find("installedVersion", UILabel.class); if (installedVersion != null) { installedVersion.bindText(new ReadOnlyBinding<String>() { @Override public String get() { ModuleSelectionInfo sel = moduleList.getSelection(); if (sel == null) { return ""; } return sel.isPresent() ? sel.getMetadata().getVersion().toString() : translationSystem.translate("${engine:menu#module-version-installed-none}"); } }); } UILabel onlineVersion = find("onlineVersion", UILabel.class); if (onlineVersion != null) { onlineVersion.bindText(new ReadOnlyBinding<String>() { @Override public String get() { ModuleSelectionInfo sel = moduleList.getSelection(); if (sel == null) { return ""; } return (sel.getOnlineVersion() != null) ? sel.getOnlineVersion().getVersion().toString() : "none"; } }); } UILabel description = find("description", UILabel.class); if (description != null) { description.bindText(new ReadOnlyBinding<String>() { @Override public String get() { ModuleMetadata moduleMetadata = moduleInfoBinding.get(); if (moduleMetadata != null) { String dependenciesNames; List<DependencyInfo> dependencies = moduleMetadata.getDependencies(); if (dependencies != null && dependencies.size() > 0) { dependenciesNames = translationSystem.translate("${engine:menu#module-dependencies-exist}") + ":" + '\n'; for (DependencyInfo dependency : dependencies) { dependenciesNames += " " + dependency.getId().toString() + '\n'; } } else { dependenciesNames = translationSystem.translate("${engine:menu#module-dependencies-empty}") + "."; } return moduleMetadata.getDescription().toString() + '\n' + '\n' + dependenciesNames; } return ""; } }); } UILabel status = find("status", UILabel.class); if (status != null) { status.bindText(new ReadOnlyBinding<String>() { @Override public String get() { ModuleSelectionInfo info = moduleList.getSelection(); if (info != null) { if (isSelectedGameplayModule(info)) { return translationSystem.translate("${engine:menu#module-status-activegameplay}"); } else if (info.isSelected() && info.isExplicitSelection()) { return translationSystem.translate("${engine:menu#module-status-activated}"); } else if (info.isSelected()) { return translationSystem.translate("${engine:menu#module-status-dependency}"); } else if (!info.isPresent()) { return translationSystem.translate("${engine:menu#module-status-notpresent}"); } else if (info.isValidToSelect()) { return translationSystem.translate("${engine:menu#module-status-available}"); } else { return translationSystem.translate("${engine:menu#module-status-error}"); } } return ""; } }); } UIButton toggleActivate = find("toggleActivation", UIButton.class); if (toggleActivate != null) { toggleActivate.subscribe(button -> { ModuleSelectionInfo info = moduleList.getSelection(); if (info != null) { // Toggle if (info.isSelected() && info.isExplicitSelection()) { deselect(info); } else if (info.isValidToSelect()) { select(info); } } }); toggleActivate.bindEnabled(new ReadOnlyBinding<Boolean>() { @Override public Boolean get() { ModuleSelectionInfo info = moduleList.getSelection(); return info != null && info.isPresent() && !isSelectedGameplayModule(info) && (info.isSelected() || info.isValidToSelect()); } }); toggleActivate.bindText(new ReadOnlyBinding<String>() { @Override public String get() { if (moduleList.getSelection() != null) { if (moduleList.getSelection().isExplicitSelection()) { return translationSystem.translate("${engine:menu#deactivate-module}"); } else { return translationSystem.translate("${engine:menu#activate-module}"); } } return translationSystem.translate("${engine:menu#activate-module}"); // button should be disabled } }); } UIButton downloadButton = find("download", UIButton.class); if (downloadButton != null) { downloadButton.subscribe(button -> { if (moduleList.getSelection() != null) { ModuleSelectionInfo info = moduleList.getSelection(); startDownloadingNewestModulesRequiredFor(info); } }); downloadButton.bindEnabled(new ReadOnlyBinding<Boolean>() { @Override public Boolean get() { try { return moduleList.getSelection().getOnlineVersion() != null; } catch (NullPointerException e) { return false; } } }); downloadButton.bindText(new ReadOnlyBinding<String>() { @Override public String get() { ModuleSelectionInfo info = moduleList.getSelection(); if (info != null && !info.isPresent()) { return translationSystem.translate("${engine:menu#download-module}"); } else { return translationSystem.translate("${engine:menu#update-module}"); } } }); } UIButton disableAll = find("disableAll", UIButton.class); if (disableAll != null) { disableAll.subscribe(button -> sortedModules.stream() .filter(info -> info.isSelected() && info.isExplicitSelection()).forEach(this::deselect)); } } WidgetUtil.trySubscribe(this, "close", button -> triggerBackAnimation()); } private void startDownloadingNewestModulesRequiredFor(ModuleSelectionInfo moduleMetadata) { List<ModuleSelectionInfo> modulesToDownload; try { modulesToDownload = getModulesRequiredToDownloadFor(moduleMetadata); } catch (DependencyResolutionFailed e) { MessagePopup messagePopup = getManager().pushScreen(MessagePopup.ASSET_URI, MessagePopup.class); messagePopup.setMessage("Depedency resolution failed", e.getMessage()); return; } Map<URL, Path> urlToTargetMap = determineDownloadUrlsFor(modulesToDownload); ConfirmPopup confirmPopup = getManager().pushScreen(ConfirmPopup.ASSET_URI, ConfirmPopup.class); confirmPopup.setMessage("Confirm Download", modulesToDownload.size() + " modules will be downloaded"); confirmPopup.setOkHandler(() -> downloadModules(urlToTargetMap)); } private void downloadModules(Map<URL, Path> urlToTargetMap) { final WaitPopup<List<Path>> popup = getManager().pushScreen(WaitPopup.ASSET_URI, WaitPopup.class); ModuleLoader loader = new ModuleLoader(moduleManager.getModuleMetadataReader()); loader.setModuleInfoPath(TerasologyConstants.MODULE_INFO_FILENAME); popup.onSuccess(paths -> { for (Path filePath : paths) { try { Module module = loader.load(filePath); modulesLookup.get(module.getId()).setLocalVersion(module); moduleManager.getRegistry().add(module); } catch (IOException e) { logger.warn("Could not load module {}", filePath.getFileName(), e); return; } updateValidToSelect(); } }); ProgressListener progressListener = progress -> popup.setMessage("Downloading required modules", String.format("Please wait ... %d%%", (int) (progress * 100f))); // to ensure that the initial message gets set: progressListener.onProgress(0); MultiFileDownloader operation = new MultiFileDownloader(urlToTargetMap, progressListener); popup.startOperation(operation, true); } private Map<URL, Path> determineDownloadUrlsFor(List<ModuleSelectionInfo> modulesToDownload) { Map<URL, Path> urlToTargetMap = Maps.newLinkedHashMap(); for (ModuleSelectionInfo moduleSelectionInfo : modulesToDownload) { ModuleMetadata metaData = moduleSelectionInfo.getOnlineVersion().getMetadata(); String version = metaData.getVersion().toString(); String id = metaData.getId().toString(); URL url = RemoteModuleExtension.getDownloadUrl(metaData); String fileName = String.format("%s-%s.jar", id, version); Path folder = PathManager.getInstance().getHomeModPath().normalize(); Path target = folder.resolve(fileName); urlToTargetMap.put(url, target); } return urlToTargetMap; } /** * @return All modules that are required to play the online version of the specified module. The list contains the * passed module too. */ private List<ModuleSelectionInfo> getModulesRequiredFor(ModuleSelectionInfo mainModuleInfo) throws DependencyResolutionFailed { ModuleMetadata mainModuleMetadata = mainModuleInfo.getOnlineVersion().getMetadata(); LinkedList<Name> idsToCheck = Lists.newLinkedList(); idsToCheck.add(mainModuleMetadata.getId()); Map<Name, ModuleSelectionInfo> requiredIdToMetaDataMap = Maps.newLinkedHashMap(); requiredIdToMetaDataMap.put(mainModuleMetadata.getId(), mainModuleInfo); while (!idsToCheck.isEmpty()) { Name moduleToCheck = idsToCheck.removeFirst(); ModuleSelectionInfo moduleToCheckInfo = requiredIdToMetaDataMap.get(moduleToCheck); ModuleMetadata metaDataOfModuleToCheck = moduleToCheckInfo.getOnlineVersion().getMetadata(); for (DependencyInfo dependencyInfo : metaDataOfModuleToCheck.getDependencies()) { Name depName = dependencyInfo.getId(); ModuleMetadata depMetaData; if (depName.equals(TerasologyConstants.ENGINE_MODULE)) { depMetaData = moduleManager.getRegistry().getLatestModuleVersion(TerasologyConstants.ENGINE_MODULE).getMetadata(); if (!dependencyInfo.versionRange().contains(depMetaData.getVersion())) { throw new DependencyResolutionFailed(String.format( "Module %s %s requires %s in version range %s, but you are using version %s", moduleToCheck, metaDataOfModuleToCheck.getVersion(), depName, dependencyInfo.versionRange(), depMetaData.getVersion())); } } else { ModuleSelectionInfo depInfo = modulesLookup.get(depName); if (depInfo == null) { throw new DependencyResolutionFailed(String.format("%s requires %s which is missing", moduleToCheck, depName)); } depMetaData = depInfo.getOnlineVersion().getMetadata(); if (!dependencyInfo.versionRange().contains(depMetaData.getVersion())) { throw new DependencyResolutionFailed(String.format( "Module %s %s requires %s in version range %s, but the online version has version %s", moduleToCheck, metaDataOfModuleToCheck.getVersion(), depName, dependencyInfo.versionRange(), depMetaData.getVersion())); } if (!requiredIdToMetaDataMap.containsKey(depName)) { idsToCheck.add(depName); requiredIdToMetaDataMap.put(depName, depInfo); } } } } List<ModuleSelectionInfo> sortedDependencies = Lists.newArrayList(requiredIdToMetaDataMap.values()); return sortedDependencies; } /** * @return all modules that need to be downloaded to use the newest version of the specified module and all its * dependencies. */ private List<ModuleSelectionInfo> getModulesRequiredToDownloadFor(ModuleSelectionInfo mainModuleInfo) throws DependencyResolutionFailed { List<ModuleSelectionInfo> requiredModules = getModulesRequiredFor(mainModuleInfo); List<ModuleSelectionInfo> modulesToDownload = requiredModules.stream().filter(ModuleSelectionInfo::isOnlineVersionNewer) .collect(Collectors.toList()); return modulesToDownload; } private void updateValidToSelect() { List<Name> selectedModules = Lists.newArrayList(); selectedModules.addAll(sortedModules.stream().filter(ModuleSelectionInfo::isSelected) .map(info -> info.getMetadata().getId()).collect(Collectors.toList())); Name[] selectedModulesArray = selectedModules.toArray(new Name[selectedModules.size()]); sortedModules.stream().filter(info -> !info.isSelected()).forEach(info -> info.setValidToSelect(resolver.resolve(info.getMetadata().getId(), selectedModulesArray).isSuccess())); } private void setSelectedVersions(ResolutionResult currentSelectionResults) { if (currentSelectionResults.isSuccess()) { for (Module module : currentSelectionResults.getModules()) { ModuleSelectionInfo info = modulesLookup.get(module.getId()); // the engine module is not listed if (info != null) { info.setSelectedVersion(module); } } } } private void updateModuleInformation() { Set<Name> filtered = ImmutableSet.of(TerasologyConstants.ENGINE_MODULE, new Name("engine-test")); for (RemoteModule remote : metaDownloader.getModules()) { ModuleSelectionInfo info = modulesLookup.get(remote.getId()); if (!filtered.contains(remote.getId())) { if (info == null) { info = ModuleSelectionInfo.remote(remote); modulesLookup.put(remote.getId(), info); int pos = Collections.binarySearch(sortedModules, info, moduleInfoComparator); if (pos < 0) { // not yet in the (sorted) list sortedModules.add(-pos - 1, info); // use "insertion point" to keep the list sorted allSortedModules.clear(); allSortedModules.addAll(sortedModules); } } info.setOnlineVersion(remote); } } } @Override public void update(float delta) { super.update(delta); if (needsUpdate) { if (metaDownloader.isDone()) { needsUpdate = false; } updateModuleInformation(); } } @Override public void onClosed() { // moduleConfig passes the module collection to the Create Game Screen. ModuleConfig moduleConfig = config.getDefaultModSelection(); moduleConfig.clear(); // Fetch all the selected/activated modules using allSortedModules // instead of fetching only selected/activated modules from filtered collection of modules using sortedModules allSortedModules.stream().filter(info -> info.isSelected() && info.isExplicitSelection()).forEach(info -> moduleConfig.addModule(info.getMetadata().getId())); SimpleUri defaultGenerator = config.getWorldGeneration().getDefaultGenerator(); ModuleSelectionInfo info = modulesLookup.get(defaultGenerator.getModuleName()); if (info != null && !info.isSelected()) { config.getWorldGeneration().setDefaultGenerator(new SimpleUri()); } worldGenManager.refresh(); config.save(); } @Override public boolean isLowerLayerVisible() { return false; } private void select(ModuleSelectionInfo target) { if (target.isValidToSelect() && !target.isExplicitSelection()) { boolean previouslySelected = target.isSelected(); target.setExplicitSelection(true); if (!previouslySelected) { refreshSelection(); } } } private List<Name> getExplicitlySelectedModules() { return sortedModules.stream().filter(ModuleSelectionInfo::isExplicitSelection).map(info -> info.getMetadata().getId()).collect(Collectors.toCollection(ArrayList::new)); } private void deselect(ModuleSelectionInfo target) { // only deselect if it is already selected and if it is not the currently selected gameplay module if (target.isExplicitSelection() && !isSelectedGameplayModule(target)) { target.setExplicitSelection(false); refreshSelection(); } } private boolean isSelectedGameplayModule(ModuleSelectionInfo target) { return target.getMetadata().getId().equals(new Name(config.getDefaultModSelection().getDefaultGameplayModuleName())); } private void refreshSelection() { List<Name> selectedModules = getExplicitlySelectedModules(); for (ModuleSelectionInfo info : sortedModules) { info.setSelectedVersion(null); } setSelectedVersions(resolver.resolve(selectedModules)); updateValidToSelect(); } private static final class DependencyResolutionFailed extends Exception { private static final long serialVersionUID = -2098680881126171195L; DependencyResolutionFailed(String message) { super(message); } } private static class MultiFileDownloader implements Callable<List<Path>> { private Map<URL, Path> urlToTargetMap; private ProgressListener progressListener; MultiFileDownloader(Map<URL, Path> urlToTargetMap, ProgressListener progressListener) { this.urlToTargetMap = urlToTargetMap; this.progressListener = progressListener; } @Override public List<Path> call() throws Exception { List<Path> downloadedFiles = new ArrayList<>(); float fractionPerFile = (float) 1 / urlToTargetMap.size(); int index = 0; for (Map.Entry<URL, Path> entry : urlToTargetMap.entrySet()) { float progressWithFiles = fractionPerFile * index; ProgressListener singleDownloadListener = fraction -> { float totalPrecentDone = progressWithFiles + (fraction / urlToTargetMap.size()); progressListener.onProgress(totalPrecentDone); }; FileDownloader fileDownloader = new FileDownloader(entry.getKey(), entry.getValue(), singleDownloadListener); downloadedFiles.add(fileDownloader.call()); index++; } return downloadedFiles; } } private static final class ModuleSelectionInfo { private Module latestVersion; private Module selectedVersion; private Module onlineVersion; private boolean explicitSelection; private boolean validToSelect = true; private ModuleSelectionInfo(Module module) { this.latestVersion = module; } public void setLocalVersion(Module module) { latestVersion = module; } public static ModuleSelectionInfo remote(Module module) { ModuleSelectionInfo info = new ModuleSelectionInfo(null); info.setOnlineVersion(module); return info; } public static ModuleSelectionInfo local(Module module) { return new ModuleSelectionInfo(module); } public ModuleMetadata getMetadata() { if (selectedVersion != null) { return selectedVersion.getMetadata(); } else if (latestVersion != null) { return latestVersion.getMetadata(); } else if (onlineVersion != null) { return onlineVersion.getMetadata(); } return null; } public boolean isPresent() { return latestVersion != null; } public boolean isSelected() { return selectedVersion != null; } public Module getOnlineVersion() { return onlineVersion; } public Module getLatestVersion() { return latestVersion; } public void setOnlineVersion(Module onlineVersion) { this.onlineVersion = onlineVersion; } public void setSelectedVersion(Module selectedVersion) { this.selectedVersion = selectedVersion; } public boolean isExplicitSelection() { return explicitSelection; } public void setExplicitSelection(boolean explicitSelection) { this.explicitSelection = explicitSelection; } public boolean isValidToSelect() { return validToSelect; } public void setValidToSelect(boolean validToSelect) { this.validToSelect = validToSelect; } public boolean isOnlineVersionNewer() { if (onlineVersion == null) { return false; } if (latestVersion == null) { return true; } int versionCompare = onlineVersion.getVersion().compareTo(latestVersion.getVersion()); if (versionCompare > 0) { return true; } else if (versionCompare == 0) { /* * Multiple binaries get released as the same snapshot version, A version name match thus does not * gurantee that we have the newest version already if it is a snapshot version. * * Having the user redownload the same binary again is not ideal, but it is better then ahving the user * being stuck on an outdated snapshot binary. */ return onlineVersion.getVersion().isSnapshot(); } else { return false; } } } }