package world.bentobox.bentobox.managers; import java.io.File; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.InputMismatchException; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.jar.JarFile; import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.InstanceCreator; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.metadata.MetaDataValue; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.blueprints.Blueprint; import world.bentobox.bentobox.blueprints.BlueprintPaster; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBlock; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBundle; import world.bentobox.bentobox.database.json.BentoboxTypeAdapterFactory; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; /** * Handles Blueprints * * @author Poslovitch, tastybento * @since 1.5.0 */ public class BlueprintsManager { private static final String BLUEPRINT_BUNDLE_SUFFIX = ".json"; public static final String BLUEPRINT_SUFFIX = ".blu"; public static final String DEFAULT_BUNDLE_NAME = "default"; @NonNull public static final String FOLDER_NAME = "blueprints"; private static final String FOR = "' for "; /** * Map of blueprint bundles to game mode addon. * Inner map's key is the uniqueId of the blueprint bundle so it's * easy to get from a UI */ @NonNull private final Map> blueprintBundles; /** * Map of blueprints. There can be many blueprints per game mode addon * Inner map's key is the blueprint's name so it's easy to get from a UI */ @NonNull private final Map> blueprints; /** * Gson used for serializing/deserializing the bundle class */ private final Gson gson; private final @NonNull BentoBox plugin; @NonNull private final Set blueprintsLoaded; public BlueprintsManager(@NonNull BentoBox plugin) { this.plugin = plugin; // Must use ConcurrentHashMap because the maps are loaded async and they need to be thread safe this.blueprintBundles = new ConcurrentHashMap<>(); this.blueprints = new ConcurrentHashMap<>(); @SuppressWarnings({"rawtypes", "unchecked"}) GsonBuilder builder = new GsonBuilder() .excludeFieldsWithoutExposeAnnotation() .enableComplexMapKeySerialization() .setPrettyPrinting() // This enables gson to deserialize enum maps .registerTypeAdapter(EnumMap.class, (InstanceCreator) type -> { Type[] types = (((ParameterizedType) type).getActualTypeArguments()); return new EnumMap((Class) types[0]); }); // Disable <>'s escaping etc. builder.disableHtmlEscaping(); // Register adapter factory builder.registerTypeAdapterFactory(new BentoboxTypeAdapterFactory(plugin)); gson = builder.create(); // Loaded tracker blueprintsLoaded = new HashSet<>(); } /** * Extracts the blueprints and bundles provided by this {@link GameModeAddon} in its .jar file. * This will do nothing if the blueprints folder already exists for this GameModeAddon. * * @param addon the {@link GameModeAddon} to extract the blueprints from. */ public void extractDefaultBlueprints(@NonNull GameModeAddon addon) { File folder = getBlueprintsFolder(addon); if (folder.exists()) { // If the folder exists, do not copy anything from the jar return; } if (!folder.exists() && !folder.mkdirs()) { plugin.logError("Could not create the '" + FOLDER_NAME + "' folder!"); plugin.logError("This might be due to incorrectly set-up write permissions on the operating system."); return; } // Get any blueprints or bundles from the jar and save them. try (JarFile jar = new JarFile(addon.getFile())) { Util.listJarFiles(jar, FOLDER_NAME, BLUEPRINT_BUNDLE_SUFFIX).forEach(name -> addon.saveResource(name, false)); Util.listJarFiles(jar, FOLDER_NAME, BLUEPRINT_SUFFIX).forEach(name -> addon.saveResource(name, false)); } catch (IOException e) { plugin.logError("Could not load blueprint files from addon jar " + e.getMessage()); } } /** * Get the blueprint bundles of this addon. * * @param addon the {@link GameModeAddon} to get the blueprint bundles. */ @NonNull public Map getBlueprintBundles(@NonNull GameModeAddon addon) { if (!blueprintBundles.containsKey(addon)) { return new HashMap<>(); } return blueprintBundles.get(addon).stream().collect(Collectors.toMap(BlueprintBundle::getUniqueId, b -> b)); } /** * Get the default blueprint bundle for game mode * @param addon - game mode addon * @return the default blueprint bundle or null if none * @since 1.8.0 */ @Nullable public BlueprintBundle getDefaultBlueprintBundle(@NonNull GameModeAddon addon) { if (blueprintBundles.containsKey(addon)) { return blueprintBundles.get(addon).stream().filter(bb -> bb.getUniqueId().equals(DEFAULT_BUNDLE_NAME)).findFirst().orElse(null); } return null; } /** * Returns a {@link File} instance of the blueprints folder of this {@link GameModeAddon}. * * @param addon the {@link GameModeAddon} * @return a {@link File} instance of the blueprints folder of this GameModeAddon. */ @NonNull private File getBlueprintsFolder(@NonNull GameModeAddon addon) { return new File(addon.getDataFolder(), FOLDER_NAME); } /** * Loads the blueprint bundles of this addon from its blueprints folder. * * @param addon the {@link GameModeAddon} to load the blueprints of. */ public void loadBlueprintBundles(@NonNull GameModeAddon addon) { // Set loading flag blueprintsLoaded.add(addon); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { // Load bundles blueprintBundles.put(addon, new ArrayList<>()); if (!loadBundles(addon)) { makeDefaults(addon); loadBundles(addon); } // Load blueprints loadBlueprints(addon); // Clear loading flag blueprintsLoaded.remove(addon); }); } /** * Check if all blueprints are loaded. Only query after all GameModes have been loaded. * @return true if all blueprints are loaded */ public boolean isBlueprintsLoaded() { return blueprintsLoaded.isEmpty(); } private boolean loadBundles(@NonNull GameModeAddon addon) { File bpf = getBlueprintsFolder(addon); if (!bpf.exists()) { plugin.logError("There is no blueprint folder for addon " + addon.getDescription().getName()); bpf.mkdirs(); } boolean loaded = false; File[] bundles = bpf.listFiles((dir, name) -> name.endsWith(BLUEPRINT_BUNDLE_SUFFIX)); if (bundles == null || bundles.length == 0) { return false; } for (File file : bundles) { try (FileReader fileReader = new FileReader(file, StandardCharsets.UTF_8)) { if (!file.getName().equals(Util.sanitizeInput(file.getName()))) { // fail on all blueprints with incorrect names. throw new InputMismatchException(file.getName()); } BlueprintBundle bb = gson.fromJson(fileReader, BlueprintBundle.class); if (bb != null) { // Make sure there is no existing bundle with the same uniqueId if (blueprintBundles.get(addon).stream().noneMatch(bundle -> bundle.getUniqueId().equals(bb.getUniqueId()))) { blueprintBundles.get(addon).add(bb); plugin.log("Loaded Blueprint Bundle '" + bb.getUniqueId() + FOR + addon.getDescription().getName() + "."); loaded = true; } else { // There is a bundle that already uses this uniqueId. // In that case, we log that and do not load the new bundle. plugin.logWarning("Could not load blueprint bundle '" + file.getName() + FOR + addon.getDescription().getName() + "."); plugin.logWarning("The uniqueId '" + bb.getUniqueId() + "' is already used by another Blueprint Bundle."); plugin.logWarning("This can occur if the Blueprint Bundles' files were manually edited."); plugin.logWarning("Please review your Blueprint Bundles' files and make sure their uniqueIds are not in duplicate."); } } } catch (Exception e) { plugin.logError("Could not load blueprint bundle '" + file.getName() + "'. Cause: " + e.getMessage() + "."); plugin.logStacktrace(e); } } return loaded; } private BlueprintBundle getDefaultBlueprintBundle() { BlueprintBundle bb = new BlueprintBundle(); bb.setIcon(Material.PAPER); bb.setUniqueId(DEFAULT_BUNDLE_NAME); bb.setDisplayName("Default bundle"); bb.setDescription(Collections.singletonList(ChatColor.AQUA + "Default bundle of blueprints")); return bb; } private Blueprint getDefaultBlueprint() { Blueprint defaultBp = new Blueprint(); defaultBp.setName("bedrock"); defaultBp.setDescription(Collections.singletonList(ChatColor.AQUA + "A bedrock block")); defaultBp.setBedrock(new Vector(0, 0, 0)); Map map = new HashMap<>(); map.put(new Vector(0, 0, 0), new BlueprintBlock("minecraft:bedrock")); defaultBp.setBlocks(map); return defaultBp; } /** * This should never be needed and is just a boot strap * * @param addon addon */ private void makeDefaults(@NonNull GameModeAddon addon) { plugin.logError("No blueprint bundles found! Creating a default one."); BlueprintBundle bb = getDefaultBlueprintBundle(); // Default blueprints Blueprint defaultBp = getDefaultBlueprint(); // Save a default "bedrock" blueprint new BlueprintClipboardManager(plugin, getBlueprintsFolder(addon)).saveBlueprint(defaultBp); // This blueprint is used for all environments bb.setBlueprint(World.Environment.NORMAL, defaultBp); bb.setBlueprint(World.Environment.NETHER, defaultBp); bb.setBlueprint(World.Environment.THE_END, defaultBp); bb.setUniqueId(DEFAULT_BUNDLE_NAME); blueprintBundles.get(addon).add(bb); this.saveBlueprintBundles(); } /** * Loads all the blueprints of this addon from its blueprints folder. * * @param addon the {@link GameModeAddon} to load the blueprints of. */ public void loadBlueprints(@NonNull GameModeAddon addon) { blueprints.put(addon, new ArrayList<>()); File bpf = getBlueprintsFolder(addon); if (!bpf.exists()) { plugin.logError("There is no blueprint folder for addon " + addon.getDescription().getName()); bpf.mkdirs(); } File[] bps = bpf.listFiles((dir, name) -> name.endsWith(BLUEPRINT_SUFFIX)); if (bps == null || bps.length == 0) { plugin.logError("No blueprints found for " + addon.getDescription().getName()); return; } for (File file : bps) { // Input sanitization is required for weirdos that edit files manually. String fileName = Util.sanitizeInput(file.getName().substring(0, file.getName().length() - BLUEPRINT_SUFFIX.length())); try { Blueprint bp = new BlueprintClipboardManager(plugin, bpf).loadBlueprint(fileName); bp.setName(fileName); blueprints.get(addon).add(bp); plugin.log("Loaded blueprint '" + bp.getName() + FOR + addon.getDescription().getName()); } catch (Exception e) { plugin.logError("Could not load blueprint " + fileName + " " + e.getMessage()); plugin.logStacktrace(e); } } } /** * Adds a blueprint to addon's list of blueprints. If the list already contains a blueprint with the same name * it is replaced. * * @param addon - the {@link GameModeAddon} * @param bp - blueprint */ public void addBlueprint(@NonNull GameModeAddon addon, @NonNull Blueprint bp) { blueprints.putIfAbsent(addon, new ArrayList<>()); blueprints.get(addon).removeIf(b -> b.getName().equals(bp.getName())); blueprints.get(addon).add(bp); plugin.log("Added blueprint '" + bp.getName() + FOR + addon.getDescription().getName()); } /** * Saves a blueprint into addon's blueprint folder * * @param addon - the {@link GameModeAddon} * @param bp - blueprint to save */ public boolean saveBlueprint(@NonNull GameModeAddon addon, @NonNull Blueprint bp) { return new BlueprintClipboardManager(plugin, getBlueprintsFolder(addon)).saveBlueprint(bp); } /** * Save blueprint bundle for game mode * * @param addon - gamemode addon * @param bb blueprint bundle to save */ public void saveBlueprintBundle(GameModeAddon addon, BlueprintBundle bb) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { File bpf = getBlueprintsFolder(addon); if (!bpf.exists()) { bpf.mkdirs(); } File fileName = new File(bpf, bb.getUniqueId() + BLUEPRINT_BUNDLE_SUFFIX); String toStore = gson.toJson(bb, BlueprintBundle.class); try (FileWriter fileWriter = new FileWriter(fileName, StandardCharsets.UTF_8)) { fileWriter.write(toStore); } catch (IOException e) { plugin.logError("Could not save blueprint bundle file: " + e.getMessage()); } }); } /** * Saves all the blueprint bundles */ public void saveBlueprintBundles() { blueprintBundles.forEach((k, v) -> v.forEach(m -> saveBlueprintBundle(k, m))); } /** * Get blueprints for this game mode * * @param addon - game mode addon * @return Map of name and blueprint or empty map */ public Map getBlueprints(GameModeAddon addon) { if (!blueprints.containsKey(addon)) { return new HashMap<>(); } return blueprints.get(addon).stream().collect(Collectors.toMap(Blueprint::getName, b -> b)); } /** * Unregisters the Blueprint from the manager and deletes the file. * @param addon game mode addon * @param name name of the Blueprint to delete * @since 1.9.0 */ public void deleteBlueprint(GameModeAddon addon, String name) { List addonBlueprints = this.blueprints.get(addon); Iterator it = addonBlueprints.iterator(); while (it.hasNext()) { Blueprint b = it.next(); if (b.getName().equalsIgnoreCase(name)) { it.remove(); File file = new File(this.getBlueprintsFolder(addon), b.getName() + BLUEPRINT_SUFFIX); // Delete the file try { Files.deleteIfExists(file.toPath()); } catch (IOException e) { this.plugin.logError("Could not delete Blueprint " + e.getLocalizedMessage()); } } } } /** * Paste the islands to world * * @param addon - GameModeAddon * @param island - island * @param name - bundle name */ public void paste(GameModeAddon addon, Island island, String name) { paste(addon, island, name, null, true); } /** * Paste islands to the world and run task afterwards * * @param addon - the game mode addon * @param island - the island * @param name - name of bundle to paste * @param task - task to run after pasting is completed * @param useNMS - true to use NMS pasting * @return true if okay, false is there is a problem */ public boolean paste(GameModeAddon addon, Island island, String name, Runnable task, boolean useNMS) { if (validate(addon, name) == null) { plugin.logError("Tried to paste '" + name + "' but the bundle is not loaded!"); return false; } BlueprintBundle bb = getBlueprintBundles(addon).get(name.toLowerCase()); if (!blueprints.containsKey(addon) || blueprints.get(addon).isEmpty()) { plugin.logError("No blueprints loaded for bundle '" + name + "'!"); return false; } Blueprint bp = getBlueprints(addon).get(bb.getBlueprint(World.Environment.NORMAL)); if (bp == null) { // Oops, no overworld bp = getBlueprints(addon).get("island"); plugin.logError("Blueprint bundle has no normal world blueprint, using default"); if (bp == null) { plugin.logError("NO DEFAULT BLUEPRINT FOUND! Make sure 'island.blu' exists!"); } } // Paste if (bp != null) { new BlueprintPaster(plugin, bp, addon.getOverWorld(), island).paste(useNMS) .thenAccept(b -> pasteNether(addon, bb, island).thenAccept( b2 -> pasteEnd(addon, bb, island).thenAccept(message -> sendMessage(island)).thenAccept(b3 -> Bukkit.getScheduler().runTask(plugin, task)))); } // Set the bundle name island.putMetaData("bundle", new MetaDataValue(name)); return true; } private CompletableFuture pasteNether(GameModeAddon addon, BlueprintBundle bb, Island island) { if (bb.getBlueprint(World.Environment.NETHER) != null && addon.getWorldSettings().isNetherGenerate() && addon.getWorldSettings().isNetherIslands() && addon.getNetherWorld() != null) { Blueprint bp = getBlueprints(addon).get(bb.getBlueprint(World.Environment.NETHER)); if (bp != null) { return new BlueprintPaster(plugin, bp, addon.getNetherWorld(), island).paste(); } } return CompletableFuture.completedFuture(false); } private CompletableFuture pasteEnd(GameModeAddon addon, BlueprintBundle bb, Island island) { // Make end island if (bb.getBlueprint(World.Environment.THE_END) != null && addon.getWorldSettings().isEndGenerate() && addon.getWorldSettings().isEndIslands() && addon.getEndWorld() != null) { Blueprint bp = getBlueprints(addon).get(bb.getBlueprint(World.Environment.THE_END)); if (bp != null) { return new BlueprintPaster(plugin, bp, addon.getEndWorld(), island).paste(); } } return CompletableFuture.completedFuture(false); } /** * This method just sends a message to the island owner that island creating is completed. * @param island Island which owner must receive a message. */ private void sendMessage(Island island) { if (island != null && island.getOwner() != null) { final Optional owner = Optional.of(island).map(i -> User.getInstance(i.getOwner())); owner.ifPresent(user -> user.sendMessage("commands.island.create.pasting.done")); } } /** * Validate if the bundle name is valid or not * * @param addon - game mode addon * @param name - bundle name * @return bundle name or null if it's invalid */ public @Nullable String validate(GameModeAddon addon, String name) { if (name == null) { return null; } if (blueprintBundles.containsKey(addon) && getBlueprintBundles(addon).containsKey(name)) { return name; } return null; } /** * Adds a blueprint bundle. If a bundle with the same uniqueId exists, it will be replaced * * @param addon - the game mode addon * @param bb - the blueprint bundle */ public void addBlueprintBundle(GameModeAddon addon, BlueprintBundle bb) { if (blueprintBundles.containsKey(addon)) { // Remove any bundles with the same name blueprintBundles.get(addon).removeIf(b -> b.getUniqueId().equals(bb.getUniqueId())); } blueprintBundles.computeIfAbsent(addon, k -> new ArrayList<>()).add(bb); } /** * Checks if a player has permission to see or use this blueprint bundle. * * @param addon - addon making the request * @param user - user making the request * @param name - name of the blueprint bundle * @return true if allowed, false if not or bundle does not exist */ public boolean checkPerm(@NonNull Addon addon, @NonNull User user, @NonNull String name) { // Permission String permission = addon.getPermissionPrefix() + "island.create." + name; // Get Blueprint bundle BlueprintBundle bb = getBlueprintBundles((GameModeAddon) addon).get(name.toLowerCase()); if (bb == null || (bb.isRequirePermission() && !name.equals(DEFAULT_BUNDLE_NAME) && !user.hasPermission(permission))) { user.sendMessage("general.errors.no-permission", TextVariables.PERMISSION, permission); return false; } return true; } /** * Removes a blueprint bundle * * @param addon - Game Mode Addon * @param bb - Blueprint Bundle to delete */ public void deleteBlueprintBundle(@NonNull GameModeAddon addon, BlueprintBundle bb) { if (blueprintBundles.containsKey(addon)) { blueprintBundles.get(addon).removeIf(k -> k.getUniqueId().equals(bb.getUniqueId())); } File bpf = getBlueprintsFolder(addon); File fileName = new File(bpf, bb.getUniqueId() + BLUEPRINT_BUNDLE_SUFFIX); try { Files.deleteIfExists(fileName.toPath()); } catch (IOException e) { plugin.logError("Could not delete Blueprint Bundle " + e.getLocalizedMessage()); } } /** * Rename a blueprint * * @param addon - Game Mode Addon * @param bp - blueprint * @param name - new name * @param displayName - display name for blueprint */ public void renameBlueprint(GameModeAddon addon, Blueprint bp, String name, String displayName) { if (bp.getName().equalsIgnoreCase(name)) { // If the name is the same, do not do anything return; } File bpf = this.getBlueprintsFolder(addon); // Get the filename File fileName = new File(bpf, bp.getName() + BLUEPRINT_SUFFIX); // Delete the old file try { Files.deleteIfExists(fileName.toPath()); } catch (IOException e) { this.plugin.logError("Could not delete old Blueprint " + e.getLocalizedMessage()); } // Remove blueprint from the blueprints. this.blueprints.get(addon).remove(bp); // Set new name bp.setName(name); bp.setDisplayName(displayName); // Save it this.saveBlueprint(addon, bp); this.addBlueprint(addon, bp); } }