package com.faforever.client.map; import com.faforever.client.config.CacheNames; import com.faforever.client.i18n.I18n; import com.faforever.client.map.MapBean.Type; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.remote.FafService; import com.faforever.client.task.CompletableTask; import com.faforever.client.task.CompletableTask.Priority; import com.faforever.client.task.TaskService; import javafx.beans.property.DoubleProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.scene.image.Image; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester; import org.apache.lucene.store.Directory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationContext; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.io.IOException; import java.lang.invoke.MethodHandles; import java.net.HttpURLConnection; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static com.faforever.client.util.LuaUtil.loadFile; import static com.github.nocatch.NoCatch.noCatch; import static com.google.common.net.UrlEscapers.urlFragmentEscaper; import static java.lang.String.format; import static java.nio.file.Files.list; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.util.stream.Collectors.toCollection; public class MapServiceImpl implements MapService { private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final float MAP_SIZE_FACTOR = 51.2f; private static final Lock LOOKUP_LOCK = new ReentrantLock(); @Resource PreferencesService preferencesService; @Resource TaskService taskService; @Resource ApplicationContext applicationContext; @Resource Directory directory; @Resource Analyzer analyzer; @Resource ThreadPoolExecutor threadPoolExecutor; @Resource FafService fafService; @Value("${vault.mapDownloadUrl}") String mapDownloadUrl; @Value("${vault.mapPreviewUrl.small}") String smallMapPreviewUrl; @Value("${vault.mapPreviewUrl.large}") String largeMapPreviewUrl; @Resource I18n i18n; private Map<Path, MapBean> pathToMap; private AnalyzingInfixSuggester suggester; private Path mapsDirectory; private ObservableList<MapBean> installedSkirmishMaps; private Map<String, MapBean> mapsByTechnicalName; public MapServiceImpl() { pathToMap = new HashMap<>(); installedSkirmishMaps = FXCollections.observableArrayList(); mapsByTechnicalName = new HashMap<>(); installedSkirmishMaps.addListener((ListChangeListener<MapBean>) change -> { while (change.next()) { for (MapBean mapBean : change.getRemoved()) { mapsByTechnicalName.remove(mapBean.getFolderName().toLowerCase()); } for (MapBean mapBean : change.getAddedSubList()) { mapsByTechnicalName.put(mapBean.getFolderName().toLowerCase(), mapBean); } } }); } private static URL getMapUrl(String mapName, String baseUrl) { return noCatch(() -> new URL(format(baseUrl, urlFragmentEscaper().escape(mapName).toLowerCase(Locale.US)))); } @PostConstruct void postConstruct() throws IOException { mapsDirectory = preferencesService.getPreferences().getForgedAlliance().getCustomMapsDirectory(); preferencesService.getPreferences().getForgedAlliance().customMapsDirectoryProperty().addListener((observable, oldValue, newValue) -> { if (newValue != null) { onMapDirectoryReady(); } }); if (mapsDirectory != null) { onMapDirectoryReady(); } suggester = new AnalyzingInfixSuggester(directory, analyzer); } private void onMapDirectoryReady() { try { Files.createDirectories(mapsDirectory); startDirectoryWatcher(mapsDirectory); } catch (IOException | InterruptedException e) { logger.warn("Could not start map directory watcher", e); // TODO notify user } loadInstalledMaps(); } private void startDirectoryWatcher(Path mapsDirectory) throws IOException, InterruptedException { threadPoolExecutor.execute(() -> noCatch(() -> { WatchService watcher = mapsDirectory.getFileSystem().newWatchService(); MapServiceImpl.this.mapsDirectory.register(watcher, ENTRY_DELETE); while (!Thread.interrupted()) { WatchKey key = watcher.take(); key.pollEvents().stream() .filter(event -> event.kind() == ENTRY_DELETE) .forEach(event -> removeMap(mapsDirectory.resolve((Path) event.context()))); key.reset(); } })); } private void loadInstalledMaps() { taskService.submitTask(new CompletableTask<Void>(Priority.LOW) { @Override protected Void call() throws Exception { updateTitle(i18n.get("mapVault.loadingMaps")); Path officialMapsPath = preferencesService.getPreferences().getForgedAlliance().getPath().resolve("maps"); try { List<Path> mapPaths = new ArrayList<>(); Files.list(mapsDirectory).collect(toCollection(() -> mapPaths)); Arrays.stream(OfficialMap.values()) .map(map -> officialMapsPath.resolve(map.name())) .collect(toCollection(() -> mapPaths)); long totalMaps = mapPaths.size(); long mapsRead = 0; for (Path mapPath : mapPaths) { updateProgress(++mapsRead, totalMaps); addSkirmishMap(mapPath); } } catch (IOException e) { logger.warn("Maps could not be read from: " + mapsDirectory, e); } return null; } }); } private void removeMap(Path path) { installedSkirmishMaps.remove(pathToMap.remove(path)); } private void addSkirmishMap(Path path) throws MapLoadException { try { MapBean mapBean = readMap(path); pathToMap.put(path, mapBean); if (!installedSkirmishMaps.contains(mapBean) && mapBean.getType() == Type.SKIRMISH) { installedSkirmishMaps.add(mapBean); } } catch (MapLoadException e) { logger.warn("Map could not be read: " + mapsDirectory.getFileName(), e); } } @Override @NotNull public MapBean readMap(Path mapFolder) throws MapLoadException { if (!Files.isDirectory(mapFolder)) { throw new MapLoadException("Not a folder: " + mapFolder.toAbsolutePath()); } Path scenarioLuaPath = noCatch(() -> list(mapFolder)) .filter(file -> file.getFileName().toString().endsWith("_scenario.lua")) .findFirst() .orElseThrow(() -> new MapLoadException("Map folder does not contain a *_scenario.lua: " + mapFolder.toAbsolutePath())); try { LuaValue luaRoot = noCatch(() -> loadFile(scenarioLuaPath), MapLoadException.class); LuaValue scenarioInfo = luaRoot.get("ScenarioInfo"); LuaValue size = scenarioInfo.get("size"); MapBean mapBean = new MapBean(); mapBean.setFolderName(mapFolder.getFileName().toString()); mapBean.setDisplayName(scenarioInfo.get("name").toString()); mapBean.setDescription(scenarioInfo.get("description").tojstring().replaceAll("<LOC .*?>", "")); mapBean.setVersion(scenarioInfo.get("map_version").toint()); mapBean.setType(Type.fromString(scenarioInfo.get("type").toString())); mapBean.setSize(new MapSize( (int) (size.get(1).toint() / MAP_SIZE_FACTOR), (int) (size.get(2).toint() / MAP_SIZE_FACTOR)) ); mapBean.setPlayers(scenarioInfo.get("Configurations").get("standard").get("teams").get(1).get("armies").length()); return mapBean; } catch (LuaError e) { throw new MapLoadException(e); } } @Override @Cacheable(value = CacheNames.SMALL_MAP_PREVIEW, unless = "#result == null") public Image loadSmallPreview(String mapName) { URL url = getMapUrl(mapName, smallMapPreviewUrl); logger.debug("Fetching small preview for map {} from {}", mapName, url); return fetchImageOrNull(url); } @Override @Cacheable(value = CacheNames.LARGE_MAP_PREVIEW, unless = "#result == null") public Image loadLargePreview(String mapName) { URL url = getMapUrl(mapName, largeMapPreviewUrl); logger.debug("Fetching large preview for map {} from {}", mapName, url); return fetchImageOrNull(url); } @Override public ObservableList<MapBean> getInstalledMaps() { return installedSkirmishMaps; } @Override public MapBean getMapBeanLocallyFromName(String mapName) { logger.debug("Trying to return {} mapInfoBean locally", mapName); for (MapBean mapBean : getInstalledMaps()) { if (mapName.equalsIgnoreCase(mapBean.getDisplayName())) { logger.debug("Found map {} locally", mapName); return mapBean; } } return null; } @Override public MapBean findMapByName(String mapName) { return fafService.findMapByName(mapName); } @Override public boolean isOfficialMap(String mapName) { return OfficialMap.fromMapName(mapName) != null; } @Override public boolean isInstalled(String mapFolderName) { return mapsByTechnicalName.containsKey(mapFolderName.toLowerCase()); } @Override public CompletionStage<Void> download(String technicalMapName) { URL mapUrl = getMapUrl(technicalMapName, mapDownloadUrl); return downloadAndInstallMap(technicalMapName, mapUrl, null, null); } @Override public CompletionStage<Void> downloadAndInstallMap(MapBean map, @Nullable DoubleProperty progressProperty, @Nullable StringProperty titleProperty) { return downloadAndInstallMap(map.getFolderName(), map.getDownloadUrl(), progressProperty, titleProperty); } @Override public CompletionStage<List<MapBean>> lookupMap(String string, int maxResults) { return CompletableFuture.supplyAsync(() -> { try { LOOKUP_LOCK.lock(); MapInfoBeanIterator iterator = new MapInfoBeanIterator(fafService.getMaps().iterator()); suggester.build(iterator); return suggester.lookup(string, maxResults, true, false).stream() .map(lookupResult -> iterator.deserialize(lookupResult.payload.bytes)) .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException(e); } finally { LOOKUP_LOCK.unlock(); } }, threadPoolExecutor).exceptionally(throwable -> { logger.warn("Lookup failed", throwable); return null; }); } @Override public CompletionStage<List<MapBean>> getMostDownloadedMaps(int count) { return fafService.getMostDownloadedMaps(count); } @Override public CompletionStage<List<MapBean>> getMostLikedMaps(int count) { return fafService.getMostLikedMaps(count); } @Override public CompletionStage<List<MapBean>> getNewestMaps(int count) { return fafService.getNewestMaps(count); } @Override public CompletionStage<List<MapBean>> getMostPlayedMaps(int count) { return fafService.getMostPlayedMaps(count); } @Override @Cacheable(CacheNames.SMALL_MAP_PREVIEW) public Image loadSmallPreview(MapBean map) { return new Image(map.getSmallThumbnailUrl().toString(), true); } @Override @Cacheable(CacheNames.LARGE_MAP_PREVIEW) public Image loadLargePreview(MapBean map) { return new Image(map.getLargeThumbnailUrl().toString(), true); } @Override public CompletionStage<Void> uninstallMap(MapBean map) { UninstallMapTask task = applicationContext.getBean(com.faforever.client.map.UninstallMapTask.class); task.setMap(map); return taskService.submitTask(task).getFuture(); } @Override public Path getPathForMap(MapBean map) { return getPathForMap(map.getFolderName()); } @Override public Path getPathForMap(String technicalName) { Path path = preferencesService.getPreferences().getForgedAlliance().getCustomMapsDirectory().resolve(technicalName); if (Files.notExists(path)) { return null; } return path; } @Override public CompletableTask<Void> uploadMap(Path mapPath, boolean ranked) { MapUploadTask mapUploadTask = applicationContext.getBean(MapUploadTask.class); mapUploadTask.setMapPath(mapPath); mapUploadTask.setRanked(ranked); return taskService.submitTask(mapUploadTask); } @Override @CacheEvict(CacheNames.MAPS) public void evictCache() { // Nothing to see here } private CompletionStage<Void> downloadAndInstallMap(String folderName, URL downloadUrl, @Nullable DoubleProperty progressProperty, @Nullable StringProperty titleProperty) { DownloadMapTask task = applicationContext.getBean(DownloadMapTask.class); task.setMapUrl(downloadUrl); task.setFolderName(folderName); if (progressProperty != null) { progressProperty.bind(task.progressProperty()); } if (titleProperty != null) { titleProperty.bind(task.titleProperty()); } return taskService.submitTask(task).getFuture() .thenAccept(aVoid -> noCatch(() -> addSkirmishMap(getPathForMap(folderName)))); } @Nullable private Image fetchImageOrNull(URL url) { try { HttpURLConnection connection = (HttpURLConnection) url.openConnection(); if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { return new Image(url.toString(), true); } logger.debug("Map preview is not available: " + url); return null; } catch (IOException e) { logger.warn("Could not fetch map preview", e); return null; } } public enum OfficialMap { SCMP_001, SCMP_002, SCMP_003, SCMP_004, SCMP_005, SCMP_006, SCMP_007, SCMP_008, SCMP_009, SCMP_010, SCMP_011, SCMP_012, SCMP_013, SCMP_014, SCMP_015, SCMP_016, SCMP_017, SCMP_018, SCMP_019, SCMP_020, SCMP_021, SCMP_022, SCMP_023, SCMP_024, SCMP_025, SCMP_026, SCMP_027, SCMP_028, SCMP_029, SCMP_030, SCMP_031, SCMP_032, SCMP_033, SCMP_034, SCMP_035, SCMP_036, SCMP_037, SCMP_038, SCMP_039, SCMP_040, X1MP_001, X1MP_002, X1MP_003, X1MP_004, X1MP_005, X1MP_006, X1MP_007, X1MP_008, X1MP_009, X1MP_010, X1MP_011, X1MP_012, X1MP_014, X1MP_017; private static final Map<String, OfficialMap> fromString; static { fromString = new HashMap<>(); for (OfficialMap officialMap : values()) { fromString.put(officialMap.name(), officialMap); } } public static OfficialMap fromMapName(String mapName) { return fromString.get(mapName.toUpperCase()); } } }