diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c54c362..5a7c88c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,19 +109,19 @@ jobs: echo "Plugin did not enabled verbose mode" exit 1 fi - if ! grep -Fq '[YamipaPlugin] Found 5 file(s) in images directory' server.log; then + if ! grep -Fq '[YamipaPlugin] [ImageStorage] Found 5 file(s) in images directory' server.log; then echo "Plugin did not read image directory" exit 1 fi - if ! grep -Fq '[YamipaPlugin] Fixed command permissions' server.log; then + if ! grep -Fq '[YamipaPlugin] [ImageCommandBridge] Fixed command permissions' server.log; then echo "Plugin did not fixed command permissions" exit 1 fi - if ! grep -Fq '[YamipaPlugin] Created FakeImage' server.log; then + if ! grep -Fq '[YamipaPlugin] [FakeImage] Created FakeImage' server.log; then echo "Plugin did not place the fake image" exit 1 fi - if ! grep -Fq '[YamipaPlugin] Invalidated FakeImage' server.log; then + if ! grep -Fq '[YamipaPlugin] [FakeImage] Invalidated FakeImage' server.log; then echo "Plugin did not remove the fake image" exit 1 fi diff --git a/README.md b/README.md index 15b22c2..80e6e41 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,50 @@ Yamipa is ready-to-go right out of the box. By default, it creates the following - `images.dat`: A file holding the list and properties (e.g. coordinates) of all placed images in your server. You shouldn't modify its contents. -You can change the default path of these files by creating a `config.yml` file in the plugin configuration directory: +You can change the path of these files by creating a `config.yml` file in the plugin configuration directory. +Here are the default configuration values if you don't specify them: ```yaml -verbose: false # Set to "true" to enable more verbose logging -animate-images: true # Set to "false" to disable GIF support -images-path: images # Path to images directory -cache-path: cache # Path to cache directory -data-path: images.dat # Path to placed images database file +verbose: false # Set to "true" to enable more verbose logging +animate-images: true # Set to "false" to disable GIF support +images-path: images # Path to images directory +cache-path: cache # Path to cache directory +data-path: images.dat # Path to placed images database file +allowed-paths: null # Set to a RegExp to limit accessible images to players +max-image-dimension: 30 # Maximum width or height in blocks allowed in images ``` +For more information on how to set a different `allowed-paths` or `max-image-dimension` value per player, see the +[Player variables](#player-variables) section. + +### Allowed paths +The variable `allowed-paths` is a regular expression that determines whether a player is allowed to see or download +an image file. If the desired path relative to the images directory matches this expression, then the player is allowed +to continue. + +If `allowed-paths` is an empty string ("") or null, then the player can read any image file or download to any path +inside the images directory. + +This regular expression must follow +[the syntax used by Java](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html). You can test your +expression beforehand using an online tool like [regex101](https://regex101.com/). +In addition, you can make use of the following special tokens: + +- `#player#`: Player name +- `#uuid#`: Player UUID (with hyphens) + +For example, if you want every player in your server to have their own subdirectory for storing files that only they +can access, plus a shared public directory, you can use the following `allowed-paths` value: +```regexp +^(private/#player#|public)/ +``` + +That way, the player "john" can see the image file at "private/john/something.jpg", but "jane" cannot. + +> **IMPORTANT!**\ +> Note that these restrictions **also apply to other entities** like NPCs, command blocks or the server console. +> However, special tokens will always match in non-player contexts (e.g., "#player#" will be interpreted as ".+"). + +### bStats This library uses bStats to anonymously report the number of installs. If you don't like this, feel free to disable it at any time by adding `enabled: false` to the [bStats configuration file](https://bstats.org/getting-started#:~:text=Disabling%20bStats) (it's ok, no hard feelings). @@ -75,17 +110,17 @@ This plugin adds the following commands: - Show help\ `/image` - Download an image from a URL and save it with another name\ - `/image download "https://www.example.com/a/b/c/1234.jpg" imagename.jpg` + `/image download "https://www.example.com/a/b/c/1234.jpg" "imagename.jpg"` - Give 10 image items to "TestPlayer" for the "test.jpg" image (3x5 blocks)\ - `/image give TestPlayer test.jpg 10 3 5` + `/image give TestPlayer "test.jpg" 10 3 5` - Give 10 image items to "TestPlayer" that will not drop an image item when removed\ - `/image give TestPlayer test.jpg 10 3 5 -DROP` + `/image give TestPlayer "test.jpg" 10 3 5 -DROP` - Start the dialog to place an image with a width of 3 blocks and auto height\ - `/image place imagename.jpg 3` + `/image place "imagename.jpg" 3` - Start the dialog to place a 3-blocks wide and 2-blocks high image\ - `/image place imagename.jpg 3 2` + `/image place "imagename.jpg" 3 2` - Start the dialog to place an image that glows in the dark\ - `/image place imagename.jpg 3 2 +GLOW` + `/image place "imagename.jpg" 3 2 +GLOW` - Start the dialog to remove a placed image while keeping the original file\ `/image remove` - Remove all placed images in a radius of 5 blocks around the spawn\ @@ -123,6 +158,28 @@ You can change which roles or players are granted these commands by using a perm such as [LuckPerms](https://luckperms.net/) or [GroupManager](https://elgarl.github.io/GroupManager/). Both these plugins have been tested to work with Yamipa, although any similar one should work just fine. +## Player variables +Some permission plugins like LuckPerms allow server operators to assign +[key-value pairs](https://luckperms.net/wiki/Meta-Commands) to entities as if they were permissions. +This is useful for granting different capabilities to different players or groups. + +Yamipa looks for the following variables which, if found, override the default configuration value that applies to all +players: + +| Variable (key) | Overrides | Description | +|:-----------------------------|:----------------------|:----------------------------------------------------------------------------------| +| `yamipa-allowed-paths` | `allowed-paths` | Regular expression that limits which paths in the images directory are accessible | +| `yamipa-max-image-dimension` | `max-image-dimension` | Maximum width or height of images and image items issued by this player or group | + +For example, if you want to limit the image size to 5x5 blocks just for the "test" player, you can run this command: +```sh +# Using LuckPerms +/lp user test meta set yamipa-max-image-dimension 5 + +# Using GroupManager +/manuaddv test yamipa-max-image-dimension 5 +``` + ## Protecting areas In large servers, letting your players place and remove images wherever they want might not be the most sensible idea. For those cases, Yamipa is compatible with other Bukkit plugins that allow creating and managing world areas. diff --git a/pom.xml b/pom.xml index 3ce577d..f992871 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.josemmo.bukkit.plugin YamipaPlugin - 1.2.13 + 1.3.0 8 @@ -62,13 +62,29 @@ provided - + org.bstats bstats-bukkit 3.0.2 + + + net.luckperms + api + 5.4 + provided + + + + + com.github.ElgarL + groupmanager + 3.2 + provided + + com.sk89q.worldguard @@ -101,7 +117,7 @@ provided - + org.jetbrains annotations diff --git a/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java b/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java index a13d7ef..934b36e 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java +++ b/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java @@ -3,6 +3,7 @@ import io.josemmo.bukkit.plugin.commands.ImageCommandBridge; import io.josemmo.bukkit.plugin.renderer.*; import io.josemmo.bukkit.plugin.storage.ImageStorage; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bstats.bukkit.Metrics; import org.bstats.charts.SimplePie; import org.bukkit.Bukkit; @@ -12,26 +13,29 @@ import org.jetbrains.annotations.Nullable; import java.awt.Color; import java.nio.file.Path; +import java.util.Objects; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Function; -import java.util.logging.Level; public class YamipaPlugin extends JavaPlugin { public static final int BSTATS_PLUGIN_ID = 10243; - private static YamipaPlugin instance; + private static final Logger LOGGER = Logger.getLogger(); + private static @Nullable YamipaPlugin INSTANCE; private boolean verbose; - private ImageStorage storage; - private ImageRenderer renderer; - private ItemService itemService; - private ScheduledExecutorService scheduler; + private @Nullable ImageStorage storage; + private @Nullable ImageRenderer renderer; + private @Nullable ItemService itemService; + private @Nullable ScheduledExecutorService scheduler; + private @Nullable Metrics metrics; /** * Get plugin instance * @return Plugin instance */ public static @NotNull YamipaPlugin getInstance() { - return instance; + Objects.requireNonNull(INSTANCE, "Cannot get plugin instance if plugin is not running"); + return INSTANCE; } /** @@ -39,6 +43,7 @@ public class YamipaPlugin extends JavaPlugin { * @return Image storage instance */ public @NotNull ImageStorage getStorage() { + Objects.requireNonNull(storage, "Cannot get storage instance if plugin is not running"); return storage; } @@ -47,6 +52,7 @@ public class YamipaPlugin extends JavaPlugin { * @return Image renderer instance */ public @NotNull ImageRenderer getRenderer() { + Objects.requireNonNull(renderer, "Cannot get renderer instance if plugin is not running"); return renderer; } @@ -55,12 +61,21 @@ public class YamipaPlugin extends JavaPlugin { * @return Tasks scheduler */ public @NotNull ScheduledExecutorService getScheduler() { + Objects.requireNonNull(scheduler, "Cannot get scheduler instance if plugin is not running"); return scheduler; } + /** + * Is verbose + * @return Whether plugin is running in verbose mode + */ + public boolean isVerbose() { + return verbose; + } + @Override public void onLoad() { - instance = this; + INSTANCE = this; } @Override @@ -68,7 +83,7 @@ public void onEnable() { // Initialize logger verbose = getConfig().getBoolean("verbose", false); if (verbose) { - info("Running on VERBOSE mode"); + LOGGER.info("Running on VERBOSE mode"); } // Register plugin commands @@ -81,21 +96,23 @@ public void onEnable() { String dataPath = getConfig().getString("data-path", "images.dat"); // Create image storage + String allowedPaths = getConfig().getString("allowed-paths", ""); storage = new ImageStorage( - basePath.resolve(imagesPath).toString(), - basePath.resolve(cachePath).toString() + basePath.resolve(imagesPath).toAbsolutePath().normalize(), + basePath.resolve(cachePath).toAbsolutePath().normalize(), + allowedPaths ); try { storage.start(); } catch (Exception e) { - log(Level.SEVERE, "Failed to initialize image storage", e); + LOGGER.severe("Failed to initialize image storage", e); } // Create image renderer boolean animateImages = getConfig().getBoolean("animate-images", true); - FakeImage.configure(animateImages); - info(animateImages ? "Enabled image animation support" : "Image animation support is disabled"); - renderer = new ImageRenderer(basePath.resolve(dataPath).toString()); + LOGGER.info(animateImages ? "Enabled image animation support" : "Image animation support is disabled"); + int maxImageDimension = getConfig().getInt("max-image-dimension", 30); + renderer = new ImageRenderer(basePath.resolve(dataPath), animateImages, maxImageDimension); renderer.start(); // Create image item service @@ -106,12 +123,12 @@ public void onEnable() { scheduler = Executors.newScheduledThreadPool(6); // Warm-up plugin dependencies - fine("Waiting for ProtocolLib to be ready..."); + LOGGER.fine("Waiting for ProtocolLib to be ready..."); scheduler.execute(() -> { FakeEntity.waitForProtocolLib(); - fine("ProtocolLib is now ready"); + LOGGER.fine("ProtocolLib is now ready"); }); - fine("Triggered map color cache warm-up"); + LOGGER.fine("Triggered map color cache warm-up"); FakeMap.pixelToIndex(Color.RED.getRGB()); // Ask for a color index to force cache generation // Initialize bStats @@ -123,82 +140,49 @@ public void onEnable() { if (number >= 10) return "10-49"; return "0-9"; }; - Metrics metrics = new Metrics(this, BSTATS_PLUGIN_ID); - metrics.addCustomChart(new SimplePie("animate_images", () -> FakeImage.isAnimationEnabled() ? "true" : "false")); + metrics = new Metrics(this, BSTATS_PLUGIN_ID); + metrics.addCustomChart(new SimplePie("animate_images", () -> animateImages ? "true" : "false")); metrics.addCustomChart(new SimplePie("number_of_image_files", () -> toStats.apply(storage.size()))); metrics.addCustomChart(new SimplePie("number_of_placed_images", () -> toStats.apply(renderer.size()))); } @Override public void onDisable() { - // Stop plugin components - storage.stop(); - renderer.stop(); - itemService.stop(); - storage = null; - renderer = null; - itemService = null; - - // Stop internal scheduler - scheduler.shutdownNow(); - scheduler = null; - - // Remove Bukkit listeners and tasks - HandlerList.unregisterAll(this); - Bukkit.getScheduler().cancelTasks(this); - } + // Stop metrics + if (metrics != null) { + metrics.shutdown(); + metrics = null; + } - /** - * Log message - * @param level Record level - * @param message Message - * @param e Throwable instance, NULL to ignore - */ - public void log(@NotNull Level level, @NotNull String message, @Nullable Throwable e) { - // Fix log level - if (level.intValue() < Level.INFO.intValue()) { - if (!verbose) return; - level = Level.INFO; + // Stop item service + if (itemService != null) { + itemService.stop(); + itemService = null; } - // Proxy record to real logger - if (e == null) { - getLogger().log(level, message); - } else { - getLogger().log(level, message, e); + // Stop image renderer + if (renderer != null) { + renderer.stop(); + renderer = null; } - } - /** - * Log message - * @param level Record level - * @param message Message - */ - public void log(@NotNull Level level, @NotNull String message) { - log(level, message, null); - } + // Stop image storage + if (storage != null) { + storage.stop(); + storage = null; + } - /** - * Log warning message - * @param message Message - */ - public void warning(@NotNull String message) { - log(Level.WARNING, message); - } + // Stop internal scheduler + if (scheduler != null) { + scheduler.shutdownNow(); + scheduler = null; + } - /** - * Log info message - * @param message Message - */ - public void info(@NotNull String message) { - log(Level.INFO, message); - } + // Remove Bukkit listeners and tasks + HandlerList.unregisterAll(this); + Bukkit.getScheduler().cancelTasks(this); - /** - * Log fine message - * @param message Message - */ - public void fine(@NotNull String message) { - log(Level.FINE, message); + // Unlink reference to instance + INSTANCE = null; } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/Command.java b/src/main/java/io/josemmo/bukkit/plugin/commands/Command.java index afaebae..bbcd959 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/Command.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/Command.java @@ -10,6 +10,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.function.BiConsumer; @@ -19,8 +20,8 @@ public class Command { private final String name; private final List arguments = new ArrayList<>(); private Predicate requirementHandler = __ -> true; - private BiConsumer executesHandler = null; - private BiConsumer executesPlayerHandler = null; + private @Nullable BiConsumer executesHandler; + private @Nullable BiConsumer executesPlayerHandler; private final List subcommands = new ArrayList<>(); /** @@ -82,6 +83,7 @@ public Command(@NotNull String name) { * @param handler Command handler * @return This instance */ + @SuppressWarnings("UnusedReturnValue") public @NotNull Command executesPlayer(@NotNull BiConsumer handler) { executesPlayerHandler = handler; return this; @@ -127,7 +129,12 @@ public Command(@NotNull String name) { // Chain command elements from the bottom-up if (argIndex < arguments.size()) { - parent.then(buildElement(arguments.get(argIndex).build(), argIndex+1)).executes(ctx -> { + Argument argument = arguments.get(argIndex); + ArgumentBuilder argumentBuilder = argument.build().suggests((ctx, builder) -> { + CommandSender sender = Internals.getBukkitSender(ctx.getSource()); + return argument.suggest(sender, builder); + }); + parent.then(buildElement(argumentBuilder, argIndex+1)).executes(ctx -> { CommandSender sender = Internals.getBukkitSender(ctx.getSource()); sender.sendMessage(ChatColor.RED + "Missing required arguments"); return 1; diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java index bdbd13d..7bb9cb1 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java @@ -5,6 +5,8 @@ import io.josemmo.bukkit.plugin.renderer.ImageRenderer; import io.josemmo.bukkit.plugin.renderer.ItemService; import io.josemmo.bukkit.plugin.storage.ImageFile; +import io.josemmo.bukkit.plugin.storage.ImageStorage; +import io.josemmo.bukkit.plugin.utils.Logger; import io.josemmo.bukkit.plugin.utils.Permissions; import io.josemmo.bukkit.plugin.utils.SelectBlockTask; import io.josemmo.bukkit.plugin.utils.ActionBar; @@ -23,13 +25,15 @@ import java.net.URL; import java.net.URLConnection; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import java.text.SimpleDateFormat; import java.util.*; public class ImageCommand { - public static final int ITEMS_PER_PAGE = 9; + private static final int ITEMS_PER_PAGE = 9; + private static final int MAX_PATH_DEPTH = 10; + private static final Logger LOGGER = Logger.getLogger("ImageCommand"); public static void showHelp(@NotNull CommandSender s, @NotNull String commandName) { String cmd = "/" + commandName; @@ -62,8 +66,9 @@ public static void showHelp(@NotNull CommandSender s, @NotNull String commandNam } public static void listImages(@NotNull CommandSender sender, int page) { - String[] filenames = YamipaPlugin.getInstance().getStorage().getAllFilenames(); - int numOfImages = filenames.length; + ImageStorage storage = YamipaPlugin.getInstance().getStorage(); + List filenames = storage.getFilenames(sender); + int numOfImages = filenames.size(); // Are there any images available? if (numOfImages == 0) { @@ -85,24 +90,42 @@ public static void listImages(@NotNull CommandSender sender, int page) { sender.sendMessage("=== Page " + page + " out of " + maxPage + " ==="); } for (int i=firstImageIndex; i MAX_PATH_DEPTH) { + sender.sendMessage(ChatColor.RED + "Destination path has too many directories"); + return; + } // Validate and fix remote URL URL url; @@ -149,6 +172,7 @@ public static void downloadImage(@NotNull CommandSender sender, @NotNull String // Download file sender.sendMessage("Downloading file..."); + Files.createDirectories(destPath.getParent()); Files.copy(conn.getInputStream(), destPath); // Validate downloaded file @@ -160,10 +184,10 @@ public static void downloadImage(@NotNull CommandSender sender, @NotNull String sender.sendMessage(ChatColor.GREEN + "Done!"); } catch (IOException e) { sender.sendMessage(ChatColor.RED + "An error occurred trying to download the remote file"); - plugin.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName()); + LOGGER.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName()); } catch (IllegalArgumentException e) { if (Files.exists(destPath) && !destPath.toFile().delete()) { - plugin.warning("Failed to delete corrupted file \"" + destPath + "\""); + LOGGER.warning("Failed to delete corrupted file \"" + destPath + "\""); } sender.sendMessage(ChatColor.RED + e.getMessage()); } @@ -183,13 +207,11 @@ public static void placeImage( player.sendMessage(ChatColor.RED + "The requested file is not a valid image"); return; } - final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, width) : height; + final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, player, width) : height; // Ask player where to place image SelectBlockTask task = new SelectBlockTask(player); - task.onSuccess((location, face) -> { - placeImage(player, image, width, finalHeight, flags, location, face); - }); + task.onSuccess((location, face) -> placeImage(player, image, width, finalHeight, flags, location, face)); task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image placing canceled")); task.run("Right click a block to continue"); } @@ -207,7 +229,7 @@ public static boolean placeImage( // Create new fake image instance Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation()); - FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation, + FakeImage fakeImage = new FakeImage(image.getFilename(), location, face, rotation, width, height, new Date(), player, flags); // Make sure image can be placed @@ -281,7 +303,7 @@ public static void clearImages( // Get images in area Set images = renderer.getImages( - origin.getWorld(), + Objects.requireNonNull(origin.getWorld()), origin.getBlockX()-radius+1, origin.getBlockX()+radius-1, origin.getBlockZ()-radius+1, @@ -446,7 +468,7 @@ public static void giveImageItems( return; } if (height == 0) { - height = FakeImage.getProportionalHeight(sizeInPixels, width); + height = FakeImage.getProportionalHeight(sizeInPixels, sender, width); } // Create item stack diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java index ea26427..f9182db 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java @@ -7,6 +7,7 @@ import io.josemmo.bukkit.plugin.renderer.FakeImage; import io.josemmo.bukkit.plugin.storage.ImageFile; import io.josemmo.bukkit.plugin.utils.Internals; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.OfflinePlayer; @@ -15,8 +16,9 @@ import org.jetbrains.annotations.NotNull; public class ImageCommandBridge { - public static final String COMMAND_NAME = "yamipa"; - public static final String[] COMMAND_ALIASES = new String[] {"image", "images"}; + private static final String COMMAND_NAME = "yamipa"; + private static final String[] COMMAND_ALIASES = new String[] {"image", "images"}; + private static final Logger LOGGER = Logger.getLogger("ImageCommandBridge"); /** * Register command @@ -42,7 +44,7 @@ public static void register(@NotNull YamipaPlugin plugin) { ); dispatcher.getRoot().addChild(aliasNode); } - plugin.fine("Registered plugin command and aliases"); + LOGGER.fine("Registered plugin command and aliases"); // Fix "minecraft.command.*" permissions Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> { @@ -50,10 +52,11 @@ public static void register(@NotNull YamipaPlugin plugin) { for (String alias : COMMAND_ALIASES) { fixPermissions(alias); } - plugin.fine("Fixed command permissions"); + LOGGER.fine("Fixed command permissions"); }); } + @SuppressWarnings("CodeBlock2Expr") private static @NotNull Command getRootCommand() { Command root = new Command(COMMAND_NAME); @@ -117,8 +120,8 @@ public static void register(@NotNull YamipaPlugin plugin) { .withArgument(new OnlinePlayerArgument("player")) .withArgument(new ImageFileArgument("filename")) .withArgument(new IntegerArgument("amount", 1, 64)) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) - .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) + .withArgument(new ImageDimensionArgument("height")) .withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_GIVE_FLAGS)) .executes((sender, args) -> { ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], @@ -129,8 +132,8 @@ public static void register(@NotNull YamipaPlugin plugin) { .withArgument(new OnlinePlayerArgument("player")) .withArgument(new ImageFileArgument("filename")) .withArgument(new IntegerArgument("amount", 1, 64)) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) - .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) + .withArgument(new ImageDimensionArgument("height")) .executes((sender, args) -> { ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], (int) args[4], (int) args[5], FakeImage.DEFAULT_GIVE_FLAGS); @@ -140,7 +143,7 @@ public static void register(@NotNull YamipaPlugin plugin) { .withArgument(new OnlinePlayerArgument("player")) .withArgument(new ImageFileArgument("filename")) .withArgument(new IntegerArgument("amount", 1, 64)) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) .executes((sender, args) -> { ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], (int) args[4], 0, FakeImage.DEFAULT_GIVE_FLAGS); @@ -164,8 +167,8 @@ public static void register(@NotNull YamipaPlugin plugin) { root.addSubcommand("place") .withPermission("yamipa.command.place", "yamipa.place") .withArgument(new ImageFileArgument("filename")) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) - .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) + .withArgument(new ImageDimensionArgument("height")) .withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_PLACE_FLAGS)) .executesPlayer((player, args) -> { ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], (int) args[4]); @@ -173,8 +176,8 @@ public static void register(@NotNull YamipaPlugin plugin) { root.addSubcommand("place") .withPermission("yamipa.command.place", "yamipa.place") .withArgument(new ImageFileArgument("filename")) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) - .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) + .withArgument(new ImageDimensionArgument("height")) .executesPlayer((player, args) -> { ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], FakeImage.DEFAULT_PLACE_FLAGS); @@ -182,7 +185,7 @@ public static void register(@NotNull YamipaPlugin plugin) { root.addSubcommand("place") .withPermission("yamipa.command.place", "yamipa.place") .withArgument(new ImageFileArgument("filename")) - .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageDimensionArgument("width")) .executesPlayer((player, args) -> { ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0, FakeImage.DEFAULT_PLACE_FLAGS); diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/Argument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/Argument.java index f5bda89..5a840fe 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/Argument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/Argument.java @@ -4,8 +4,11 @@ import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; +import java.util.concurrent.CompletableFuture; public abstract class Argument { protected final String name; @@ -28,10 +31,20 @@ public Argument(@NotNull String name) { /** * Build argument - * @return Required Argument Builder instance + * @return Argument builder instance */ public abstract @NotNull RequiredArgumentBuilder build(); + /** + * Suggest argument values + * @param sender Command sender + * @param builder Suggestions builder instance + * @return Suggestions + */ + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { + return builder.buildFuture(); + } + /** * Parse argument value * @param sender Command sender @@ -42,6 +55,11 @@ public Argument(@NotNull String name) { return rawValue; } + /** + * Create new syntax exception + * @param message Message to show + * @return Syntax exception + */ protected @NotNull CommandSyntaxException newException(@NotNull String message) { return new SimpleCommandExceptionType(new LiteralMessage(message)).create(); } diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageDimensionArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageDimensionArgument.java new file mode 100644 index 0000000..5434d60 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageDimensionArgument.java @@ -0,0 +1,26 @@ +package io.josemmo.bukkit.plugin.commands.arguments; + +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import io.josemmo.bukkit.plugin.renderer.FakeImage; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +public class ImageDimensionArgument extends IntegerArgument { + /** + * Dimension Argument constructor + * @param name Argument name + */ + public ImageDimensionArgument(@NotNull String name) { + super(name, 1); + } + + @Override + public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException { + int maxDimension = FakeImage.getMaxImageDimension(sender); + int value = (int) rawValue; + if (value > maxDimension) { + throw newException("Image cannot be larger than " + maxDimension + "x" + maxDimension); + } + return value; + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFileArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFileArgument.java index 877fc43..8b4162f 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFileArgument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFileArgument.java @@ -1,12 +1,12 @@ package io.josemmo.bukkit.plugin.commands.arguments; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.josemmo.bukkit.plugin.YamipaPlugin; import io.josemmo.bukkit.plugin.storage.ImageFile; +import io.josemmo.bukkit.plugin.storage.ImageStorage; import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; import java.util.concurrent.CompletableFuture; @@ -21,26 +21,21 @@ public ImageFileArgument(@NotNull String name) { } @Override - public @NotNull RequiredArgumentBuilder build() { - return super.build().suggests(this::getSuggestions); + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { + for (String filename : YamipaPlugin.getInstance().getStorage().getFilenames(sender)) { + builder.suggest(StringArgumentType.escapeIfRequired(filename)); + } + return builder.buildFuture(); } @Override public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException { - ImageFile imageFile = YamipaPlugin.getInstance().getStorage().get((String) rawValue); - if (imageFile == null) { + String filename = (String) rawValue; + ImageStorage storage = YamipaPlugin.getInstance().getStorage(); + ImageFile imageFile = storage.get(filename); + if (imageFile == null || !storage.isPathAllowed(filename, sender)) { throw newException("Image file does not exist"); } return imageFile; } - - private @NotNull CompletableFuture getSuggestions( - @NotNull CommandContext ctx, - @NotNull SuggestionsBuilder builder - ) { - for (String filename : YamipaPlugin.getInstance().getStorage().getAllFilenames()) { - builder.suggest(filename); - } - return builder.buildFuture(); - } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java index cfe4576..48cd985 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java @@ -2,7 +2,6 @@ import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; @@ -28,13 +27,11 @@ public ImageFlagsArgument(@NotNull String name, int defaultFlags) { @Override public @NotNull RequiredArgumentBuilder build() { - return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString()).suggests(this::getSuggestions); + return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString()); } - private @NotNull CompletableFuture getSuggestions( - @NotNull CommandContext ctx, - @NotNull SuggestionsBuilder builder - ) { + @Override + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { String input = builder.getRemaining().replaceAll("[^A-Z+\\-,]", ""); int lastIndex = Collections.max( Arrays.asList(input.lastIndexOf(","), input.lastIndexOf("+"), input.lastIndexOf("-")) diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java index 51350f2..6957534 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java @@ -1,7 +1,5 @@ package io.josemmo.bukkit.plugin.commands.arguments; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; @@ -23,8 +21,9 @@ public OnlinePlayerArgument(@NotNull String name) { } @Override - public @NotNull RequiredArgumentBuilder build() { - return super.build().suggests(this::getSuggestions); + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { + getAllowedValues().keySet().forEach(builder::suggest); + return builder.buildFuture(); } @Override @@ -36,14 +35,6 @@ public OnlinePlayerArgument(@NotNull String name) { return player; } - private @NotNull CompletableFuture getSuggestions( - @NotNull CommandContext ctx, - @NotNull SuggestionsBuilder builder - ) { - getAllowedValues().keySet().forEach(builder::suggest); - return builder.buildFuture(); - } - private @NotNull Map getAllowedValues() { Map values = new HashMap<>(); for (Player player : Bukkit.getOnlinePlayers()) { diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/PlacedByArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/PlacedByArgument.java index 45f87a8..a575bf1 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/PlacedByArgument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/PlacedByArgument.java @@ -1,7 +1,5 @@ package io.josemmo.bukkit.plugin.commands.arguments; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; @@ -24,8 +22,9 @@ public PlacedByArgument(@NotNull String name) { } @Override - public @NotNull RequiredArgumentBuilder build() { - return super.build().suggests(this::getSuggestions); + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { + getAllowedValues().keySet().forEach(builder::suggest); + return builder.buildFuture(); } @Override @@ -37,14 +36,6 @@ public PlacedByArgument(@NotNull String name) { return player; } - private @NotNull CompletableFuture getSuggestions( - @NotNull CommandContext ctx, - @NotNull SuggestionsBuilder builder - ) { - getAllowedValues().keySet().forEach(builder::suggest); - return builder.buildFuture(); - } - private @NotNull Map getAllowedValues() { Map values = new HashMap<>(); ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer(); diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/WorldArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/WorldArgument.java index 388cf95..523a79f 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/WorldArgument.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/WorldArgument.java @@ -1,7 +1,5 @@ package io.josemmo.bukkit.plugin.commands.arguments; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; @@ -21,8 +19,11 @@ public WorldArgument(@NotNull String name) { } @Override - public @NotNull RequiredArgumentBuilder build() { - return super.build().suggests(this::getSuggestions); + public @NotNull CompletableFuture suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) { + for (World world : Bukkit.getWorlds()) { + builder.suggest(world.getName()); + } + return builder.buildFuture(); } @Override @@ -33,14 +34,4 @@ public WorldArgument(@NotNull String name) { } return world; } - - private @NotNull CompletableFuture getSuggestions( - @NotNull CommandContext ctx, - @NotNull SuggestionsBuilder builder - ) { - for (World world : Bukkit.getWorlds()) { - builder.suggest(world.getName()); - } - return builder.buildFuture(); - } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java index 419e524..906ae32 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java @@ -8,31 +8,32 @@ import com.comphenix.protocol.wrappers.WrappedDataWatcher; import io.josemmo.bukkit.plugin.YamipaPlugin; import io.josemmo.bukkit.plugin.utils.Internals; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Field; -import java.util.logging.Level; public abstract class FakeEntity { - protected static final YamipaPlugin plugin = YamipaPlugin.getInstance(); - private static final ProtocolManager connection = ProtocolLibrary.getProtocolManager(); - private static PlayerInjectionHandler playerInjectionHandler = null; - private static boolean ready = false; + private static final Logger LOGGER = Logger.getLogger("FakeEntity"); + private static final ProtocolManager CONNECTION = ProtocolLibrary.getProtocolManager(); + private static @Nullable PlayerInjectionHandler PLAYER_INJECTION_HANDLER; + private static boolean READY = false; static { try { - for (Field field : connection.getClass().getDeclaredFields()) { + for (Field field : CONNECTION.getClass().getDeclaredFields()) { if (field.getType().equals(PlayerInjectionHandler.class)) { field.setAccessible(true); - playerInjectionHandler = (PlayerInjectionHandler) field.get(connection); + PLAYER_INJECTION_HANDLER = (PlayerInjectionHandler) field.get(CONNECTION); break; } } - if (playerInjectionHandler == null) { + if (PLAYER_INJECTION_HANDLER == null) { throw new RuntimeException("No valid candidate field found in ProtocolManager"); } } catch (Exception e) { - plugin.log(Level.SEVERE, "Failed to get PlayerInjectionHandler from ProtocolLib", e); + LOGGER.severe("Failed to get PlayerInjectionHandler from ProtocolLib", e); } } @@ -42,7 +43,7 @@ public abstract class FakeEntity { * NOTE: Will wait synchronously, blocking the invoker thread */ public static synchronized void waitForProtocolLib() { - if (ready) { + if (READY) { // ProtocolLib is ready return; } @@ -51,7 +52,7 @@ public static synchronized void waitForProtocolLib() { while (true) { try { WrappedDataWatcher.Registry.get(Byte.class); - ready = true; + READY = true; break; } catch (Exception e) { if (++retry > 20) { @@ -84,15 +85,15 @@ protected static void tryToSleep(long ms) { */ protected static void tryToSendPacket(@NotNull Player player, @NotNull PacketContainer packet) { try { - if (playerInjectionHandler == null) { // Use single-threaded packet sending if reflection failed - connection.sendServerPacket(player, packet); + if (PLAYER_INJECTION_HANDLER == null) { // Use single-threaded packet sending if reflection failed + CONNECTION.sendServerPacket(player, packet); } else { // Use non-blocking packet sending if available (faster, the expected case) - playerInjectionHandler.sendServerPacket(player, packet, null, false); + PLAYER_INJECTION_HANDLER.sendServerPacket(player, packet, null, false); } } catch (IllegalStateException e) { // Server is shutting down and cannot send the packet, ignore } catch (Exception e) { - plugin.log(Level.SEVERE, "Failed to send FakeEntity packet", e); + LOGGER.severe("Failed to send FakeEntity packet", e); } } @@ -118,6 +119,6 @@ protected static void tryToSendPackets(@NotNull Player player, @NotNull Iterable * @param callback Callback to execute */ protected static void tryToRunAsyncTask(@NotNull Runnable callback) { - plugin.getScheduler().execute(callback); + YamipaPlugin.getInstance().getScheduler().execute(callback); } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java index 7740de8..8fe4adb 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java @@ -1,12 +1,17 @@ package io.josemmo.bukkit.plugin.renderer; import com.comphenix.protocol.events.PacketContainer; +import io.josemmo.bukkit.plugin.YamipaPlugin; +import io.josemmo.bukkit.plugin.storage.CachedMapsFile; import io.josemmo.bukkit.plugin.storage.ImageFile; import io.josemmo.bukkit.plugin.utils.DirectionUtils; +import io.josemmo.bukkit.plugin.utils.Logger; +import io.josemmo.bukkit.plugin.utils.Permissions; import org.bukkit.Location; import org.bukkit.OfflinePlayer; import org.bukkit.Rotation; import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; import org.jetbrains.annotations.NotNull; @@ -19,7 +24,9 @@ import java.util.function.BiFunction; public class FakeImage extends FakeEntity { - public static final int MAX_DIMENSION = 30; // In blocks + private static final Logger LOGGER = Logger.getLogger("FakeImage"); + + // Image constants public static final int MAX_STEPS = 500; // For animated images public static final int MIN_DELAY = 1; // Minimum step delay in 50ms intervals (50ms / 50ms) public static final int MAX_DELAY = 50; // Maximum step delay in 50ms intervals (5000ms / 50ms) @@ -45,7 +52,7 @@ public class FakeImage extends FakeEntity { private final int flags; private final BiFunction getLocationVector; private final Set observingPlayers = new HashSet<>(); - private Runnable onLoadedListener = null; + private @Nullable Runnable onLoadedListener = null; // Generated values private boolean loading = false; @@ -54,26 +61,9 @@ public class FakeImage extends FakeEntity { private int numOfSteps = -1; // Total number of animation steps // Animation task attributes - private static boolean animateImages = false; - private ScheduledFuture task; + private @Nullable ScheduledFuture task; private int currentStep = -1; // Current animation step - /** - * Configure class - * @param animImages Animate images - */ - public static void configure(boolean animImages) { - animateImages = animImages; - } - - /** - * Is animation enabled - * @return Is animation enabled - */ - public static boolean isAnimationEnabled() { - return animateImages; - } - /** * Get image rotation from player eyesight * @param face Image block face @@ -100,16 +90,36 @@ public static boolean isAnimationEnabled() { } } + /** + * Get maximum image dimension + * @param sender Sender instance + * @return Maximum image dimension in blocks + */ + public static int getMaxImageDimension(@NotNull CommandSender sender) { + if (sender instanceof Player) { + String rawValue = Permissions.getVariable("yamipa-max-image-dimension", (Player) sender); + if (rawValue != null) { + try { + return Integer.parseInt(rawValue); + } catch (NumberFormatException __) { + LOGGER.warning("Max. image dimension for " + sender + " is not a valid integer: \"" + rawValue + "\""); + } + } + } + return YamipaPlugin.getInstance().getRenderer().getMaxImageDimension(); + } + /** * Get proportional height * @param sizeInPixels Image file dimension in pixels + * @param sender Sender instance * @param width Desired width in blocks - * @return Height in blocks (capped at FakeImage.MAX_DIMENSION) + * @return Height in blocks (capped at maximum image dimension for sender) */ - public static int getProportionalHeight(@NotNull Dimension sizeInPixels, int width) { + public static int getProportionalHeight(@NotNull Dimension sizeInPixels, @NotNull CommandSender sender, int width) { float imageRatio = (float) sizeInPixels.height / sizeInPixels.width; int height = Math.round(width * imageRatio); - height = Math.min(height, MAX_DIMENSION); + height = Math.min(height, getMaxImageDimension(sender)); return height; } @@ -177,7 +187,7 @@ public FakeImage( } } - plugin.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")"); + LOGGER.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")"); } /** @@ -193,7 +203,7 @@ public FakeImage( * @return Image file instance or NULL if not found */ public @Nullable ImageFile getFile() { - return plugin.getStorage().get(filename); + return YamipaPlugin.getInstance().getStorage().get(filename); } /** @@ -310,6 +320,7 @@ public int getDelay() { * @param face Block face * @return TRUE for contained, FALSE otherwise */ + @SuppressWarnings("RedundantIfStatement") public boolean contains(@NotNull Location location, @NotNull BlockFace face) { // Is point facing the same plane as the image? if (face != this.face) { @@ -350,18 +361,18 @@ public void setOnLoadedListener(@NotNull Runnable onLoadedListener) { */ private void load() { ImageFile file = getFile(); - FakeMapsContainer container; + + // Get maps to use + FakeMap[][][] maps; if (file == null) { - container = FakeMap.getErrorMatrix(width, height); - plugin.warning("File \"" + filename + "\" does not exist"); + maps = FakeMap.getErrorMatrix(width, height); + LOGGER.warning("File \"" + filename + "\" does not exist"); } else { - container = file.getMapsAndSubscribe(this); + CachedMapsFile cachedMapsFile = file.getMapsAndSubscribe(this); + maps = cachedMapsFile.getMaps(); + delay = cachedMapsFile.getDelay(); } - - // Extract data from container - FakeMap[][][] maps = container.getFakeMaps(); numOfSteps = maps[0][0].length; - delay = container.getDelay(); // Generate frames FakeItemFrame[] newFrames = new FakeItemFrame[width*height]; @@ -375,14 +386,16 @@ private void load() { frames = newFrames; // Start animation task (if needed) - if (animateImages && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) { + YamipaPlugin plugin = YamipaPlugin.getInstance(); + boolean isAnimationEnabled = plugin.getRenderer().isAnimationEnabled(); + if (isAnimationEnabled && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) { task = plugin.getScheduler().scheduleAtFixedRate( this::nextStep, 0, delay*50L, TimeUnit.MILLISECONDS ); - plugin.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")"); + LOGGER.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")"); } // Notify listener @@ -397,7 +410,7 @@ private void load() { * @param player Player instance */ public void spawn(@NotNull Player player) { - plugin.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName()); + LOGGER.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName()); // Send pixels if instance is already loaded if (frames != null) { @@ -439,7 +452,7 @@ private void spawnOnceLoaded(@NotNull Player player) { for (FakeItemFrame frame : frames) { packets.add(frame.getSpawnPacket()); packets.addAll(frame.getRenderPackets(player, 0)); - plugin.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName); + LOGGER.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName); } // Send packets @@ -460,7 +473,7 @@ public void destroy() { * @param player Player instance or NULL for all observing players */ public void destroy(@Nullable Player player) { - plugin.fine( + LOGGER.fine( "Received request to destroy FakeImage#(" + location + "," + face + ") for " + (player == null ? "all players" : "Player#" + player.getName()) ); @@ -473,7 +486,7 @@ public void destroy(@Nullable Player player) { List packets = new ArrayList<>(); for (FakeItemFrame frame : frames) { packets.add(frame.getDestroyPacket()); - plugin.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName); + LOGGER.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName); } tryToSendPackets(target, packets); } @@ -514,12 +527,13 @@ private void invalidate() { task.cancel(true); task = null; currentStep = -1; - plugin.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")"); + LOGGER.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")"); } // Free array of fake item frames frames = null; - plugin.fine("Invalidated FakeImage#(" + location + "," + face + ")"); + loading = false; + LOGGER.fine("Invalidated FakeImage#(" + location + "," + face + ")"); // Notify invalidation to source ImageFile ImageFile file = getFile(); diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java index 0a6f4ec..377bbfc 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java @@ -8,6 +8,7 @@ import io.josemmo.bukkit.plugin.packets.EntityMetadataPacket; import io.josemmo.bukkit.plugin.packets.SpawnEntityPacket; import io.josemmo.bukkit.plugin.utils.Internals; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Rotation; @@ -24,7 +25,8 @@ public class FakeItemFrame extends FakeEntity { public static final int MIN_FRAME_ID = Integer.MAX_VALUE / 4; public static final int MAX_FRAME_ID = Integer.MAX_VALUE; private static final boolean SUPPORTS_GLOWING = Internals.MINECRAFT_VERSION >= 17; - private static final AtomicInteger lastFrameId = new AtomicInteger(MAX_FRAME_ID); + private static final Logger LOGGER = Logger.getLogger("FakeItemFrame"); + private static final AtomicInteger LAST_FRAME_ID = new AtomicInteger(MAX_FRAME_ID); private final int id; private final Location location; private final BlockFace face; @@ -37,8 +39,8 @@ public class FakeItemFrame extends FakeEntity { * @return Next unused item frame ID */ private static int getNextId() { - return lastFrameId.updateAndGet(lastId -> { - if (lastId >= MAX_FRAME_ID) { + return LAST_FRAME_ID.updateAndGet(lastId -> { + if (lastId == MAX_FRAME_ID) { return MIN_FRAME_ID; } return lastId + 1; @@ -66,7 +68,7 @@ public FakeItemFrame( this.rotation = rotation; this.glowing = glowing; this.maps = maps; - plugin.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)"); + LOGGER.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)"); } /** diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java index a063bb8..dd56684 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java @@ -1,9 +1,11 @@ package io.josemmo.bukkit.plugin.renderer; import io.josemmo.bukkit.plugin.packets.MapDataPacket; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.entity.Player; import org.bukkit.map.MapPalette; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.awt.*; import java.time.Instant; import java.util.Arrays; @@ -14,11 +16,12 @@ public class FakeMap extends FakeEntity { public static final int DIMENSION = 128; - public static final int MIN_MAP_ID = Integer.MAX_VALUE / 4; - public static final int MAX_MAP_ID = Integer.MAX_VALUE; - public static final int RESEND_THRESHOLD = 60*5; // Seconds after sending pixels when resending should be avoided - private static final AtomicInteger lastMapId = new AtomicInteger(-1); - private static FakeMap errorInstance; + private static final int MIN_MAP_ID = Integer.MAX_VALUE / 4; + private static final int MAX_MAP_ID = Integer.MAX_VALUE; + private static final int RESEND_THRESHOLD = 60*5; // Seconds after sending pixels when resending should be avoided + private static final Logger LOGGER = Logger.getLogger("FakeMap"); + private static final AtomicInteger LAST_MAP_ID = new AtomicInteger(MIN_MAP_ID); + private static @Nullable FakeMap ERROR_INSTANCE; private final int id; private final byte[] pixels; private final ConcurrentMap lastPlayerSendTime = new ConcurrentHashMap<>(); @@ -28,8 +31,8 @@ public class FakeMap extends FakeEntity { * @return Next unused map ID */ private static int getNextId() { - return lastMapId.updateAndGet(lastId -> { - if (lastId <= MIN_MAP_ID) { + return LAST_MAP_ID.updateAndGet(lastId -> { + if (lastId == MIN_MAP_ID) { return MAX_MAP_ID; } return lastId - 1; @@ -51,27 +54,27 @@ public static byte pixelToIndex(int pixel) { * @return Error instance */ private static @NotNull FakeMap getErrorInstance() { - if (errorInstance == null) { + if (ERROR_INSTANCE == null) { byte[] pixels = new byte[DIMENSION * DIMENSION]; Arrays.fill(pixels, pixelToIndex(Color.RED.getRGB())); - errorInstance = new FakeMap(pixels); + ERROR_INSTANCE = new FakeMap(pixels); } - return errorInstance; + return ERROR_INSTANCE; } /** * Get matrix of error maps * @param width Width in blocks * @param height Height in blocks - * @return Fake maps container + * @return Fake maps */ - public static @NotNull FakeMapsContainer getErrorMatrix(int width, int height) { + public static @NotNull FakeMap[][][] getErrorMatrix(int width, int height) { FakeMap[] errorMaps = new FakeMap[] {getErrorInstance()}; FakeMap[][][] matrix = new FakeMap[width][height][1]; for (FakeMap[][] column : matrix) { Arrays.fill(column, errorMaps); } - return new FakeMapsContainer(matrix, 0); + return matrix; } /** @@ -90,7 +93,7 @@ public FakeMap(byte[] pixels, int scanSize, int startX, int startY) { System.arraycopy(pixels, startX+(startY+y)*scanSize, this.pixels, y*DIMENSION, DIMENSION); } - plugin.fine("Created FakeMap#" + this.id); + LOGGER.fine("Created FakeMap#" + this.id); } /** @@ -100,7 +103,7 @@ public FakeMap(byte[] pixels, int scanSize, int startX, int startY) { public FakeMap(byte[] pixels) { this.id = getNextId(); this.pixels = pixels; - plugin.fine("Created FakeMap#" + this.id); + LOGGER.fine("Created FakeMap#" + this.id); } /** @@ -136,7 +139,7 @@ public boolean requestResend(@NotNull Player player) { // Authorize re-send and update latest timestamp lastPlayerSendTime.put(uuid, now); - plugin.fine("Granted sending pixels for FakeMap#" + id + " to Player#" + player.getName()); + LOGGER.fine("Granted sending pixels for FakeMap#" + id + " to Player#" + player.getName()); return true; } diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMapsContainer.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMapsContainer.java deleted file mode 100644 index 8bb07b8..0000000 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMapsContainer.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.josemmo.bukkit.plugin.renderer; - -public class FakeMapsContainer { - private final FakeMap[][][] fakeMaps; - private final int delay; - - public FakeMapsContainer(FakeMap[][][] fakeMaps, int delay) { - this.fakeMaps = fakeMaps; - this.delay = delay; - } - - /** - * Get fake maps - * @return Tri-dimensional array of maps (column, row, step) - */ - public FakeMap[][][] getFakeMaps() { - return fakeMaps; - } - - /** - * Get delay between steps - * @return Delay in 50ms intervals or 0 if not applicable - */ - public int getDelay() { - return delay; - } -} diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java index deb0d59..7685460 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java @@ -2,6 +2,7 @@ import io.josemmo.bukkit.plugin.YamipaPlugin; import io.josemmo.bukkit.plugin.utils.CsvConfiguration; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.*; import org.bukkit.block.BlockFace; import org.bukkit.entity.Entity; @@ -17,18 +18,19 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Paths; +import java.nio.file.Path; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.Level; import java.util.stream.Collectors; public class ImageRenderer implements Listener { - public static final long SAVE_INTERVAL = 20L * 90; // In server ticks - private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); - private final String configPath; + private static final long SAVE_INTERVAL = 20L * 90; // In server ticks + private static final Logger LOGGER = Logger.getLogger("ImageRenderer"); + private final Path configPath; + private final boolean animateImages; + private final int maxImageDimension; private BukkitTask saveTask; private final AtomicBoolean hasConfigChanged = new AtomicBoolean(false); private final ConcurrentMap> images = new ConcurrentHashMap<>(); @@ -37,10 +39,30 @@ public class ImageRenderer implements Listener { /** * Class constructor - * @param configPath Path to configuration file + * @param configPath Path to configuration file + * @param animateImages Whether to animate images or not + * @param maxImageDimension Maximum image dimension in blocks */ - public ImageRenderer(@NotNull String configPath) { + public ImageRenderer(@NotNull Path configPath, boolean animateImages, int maxImageDimension) { this.configPath = configPath; + this.animateImages = animateImages; + this.maxImageDimension = maxImageDimension; + } + + /** + * Is animation enabled + * @return Is animation enabled + */ + public boolean isAnimationEnabled() { + return animateImages; + } + + /** + * Get maximum image dimension + * @return Maximum image dimension in blocks + */ + public int getMaxImageDimension() { + return maxImageDimension; } /** @@ -48,6 +70,7 @@ public ImageRenderer(@NotNull String configPath) { */ public void start() { loadConfig(); + YamipaPlugin plugin = YamipaPlugin.getInstance(); plugin.getServer().getPluginManager().registerEvents(this, plugin); saveTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::saveConfig, SAVE_INTERVAL, SAVE_INTERVAL); } @@ -81,8 +104,8 @@ public void stop() { * Load configuration from disk */ private void loadConfig() { - if (!Files.isRegularFile(Paths.get(configPath))) { - plugin.info("No placed fake images configuration file found"); + if (!Files.isRegularFile(configPath)) { + LOGGER.info("No placed fake images configuration file found"); return; } @@ -91,7 +114,7 @@ private void loadConfig() { try { config.load(configPath); } catch (IOException e) { - plugin.log(Level.SEVERE, "Failed to load placed fake images from disk", e); + LOGGER.severe("Failed to load placed fake images from disk", e); return; } @@ -99,19 +122,19 @@ private void loadConfig() { for (String[] row : config.getRows()) { try { String filename = row[0]; - World world = Objects.requireNonNull(plugin.getServer().getWorld(row[1])); + World world = Objects.requireNonNull(YamipaPlugin.getInstance().getServer().getWorld(row[1])); double x = Integer.parseInt(row[2]); double y = Integer.parseInt(row[3]); double z = Integer.parseInt(row[4]); Location location = new Location(world, x, y, z); BlockFace face = BlockFace.valueOf(row[5]); Rotation rotation = Rotation.valueOf(row[6]); - int width = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[7]))); - int height = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[8]))); - Date placedAt = (row.length > 9 && !row[9].equals("")) ? + int width = Math.abs(Integer.parseInt(row[7])); + int height = Math.abs(Integer.parseInt(row[8])); + Date placedAt = (row.length > 9 && !row[9].isEmpty()) ? new Date(Long.parseLong(row[9])*1000L) : null; - UUID placedById = (row.length > 10 && !row[10].equals("")) ? + UUID placedById = (row.length > 10 && !row[10].isEmpty()) ? UUID.fromString(row[10]) : FakeImage.UNKNOWN_PLAYER_ID; OfflinePlayer placedBy = Bukkit.getOfflinePlayer(placedById); @@ -122,7 +145,7 @@ private void loadConfig() { placedAt, placedBy, flags); addImage(fakeImage, true); } catch (Exception e) { - plugin.log(Level.SEVERE, "Invalid fake image properties: " + String.join(";", row), e); + LOGGER.severe("Invalid fake image properties: " + String.join(";", row), e); } } } @@ -130,6 +153,7 @@ private void loadConfig() { /** * Save configuration to disk */ + @SuppressWarnings("ExtractMethodRecommender") private void saveConfig() { if (!hasConfigChanged.get()) return; @@ -167,9 +191,9 @@ private void saveConfig() { // Write to disk try { config.save(configPath); - plugin.info("Saved placed fake images to disk"); + LOGGER.info("Saved placed fake images to disk"); } catch (IOException e) { - plugin.log(Level.SEVERE, "Failed to save placed fake images to disk", e); + LOGGER.severe("Failed to save placed fake images to disk", e); } } @@ -184,7 +208,7 @@ public void addImage(@NotNull FakeImage image, boolean isInit) { // Add image to renderer for (WorldAreaId worldAreaId : imageWorldAreaIds) { images.computeIfAbsent(worldAreaId, __ -> { - plugin.fine("Created WorldArea#(" + worldAreaId + ")"); + LOGGER.fine("Created WorldArea#(" + worldAreaId + ")"); return ConcurrentHashMap.newKeySet(); }).add(image); } @@ -243,7 +267,7 @@ public void addImage(@NotNull FakeImage image) { * @param maxX Maximum X coordinate * @param minZ Minimum Z coordinate * @param maxZ Maximum Z coordinate - * @return List of found images + * @return Set of found images */ public @NotNull Set getImages(@NotNull World world, int minX, int maxX, int minZ, int maxZ) { Set response = new HashSet<>(); @@ -274,7 +298,7 @@ public void removeImage(@NotNull FakeImage image) { Set worldAreaImages = images.get(worldAreaId); worldAreaImages.remove(image); if (worldAreaImages.isEmpty()) { - plugin.fine("Destroyed WorldArea#(" + worldAreaId + ")"); + LOGGER.fine("Destroyed WorldArea#(" + worldAreaId + ")"); images.remove(worldAreaId); } } @@ -366,7 +390,7 @@ public int size() { private void onPlayerLocationChange(@NotNull Player player, @NotNull Location location) { // Ignore NPC events from other plugins if (player.hasMetadata("NPC")) { - plugin.fine("Ignored NPC event from Player#" + player.getName()); + LOGGER.fine("Ignored NPC event from Player#" + player.getName()); return; } @@ -377,7 +401,7 @@ private void onPlayerLocationChange(@NotNull Player player, @NotNull Location lo return; } playersLocation.put(player, worldAreaId); - plugin.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")"); + LOGGER.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")"); // Get images that should be spawned/destroyed Set desiredState = getImagesInViewDistance(worldAreaId); @@ -428,9 +452,8 @@ public void onPlayerTeleport(@NotNull PlayerTeleportEvent event) { // Wait until next server tick before handling location change // This is necessary as teleport events get fired *before* teleporting the player - Bukkit.getScheduler().runTask(plugin, () -> { - onPlayerLocationChange(event.getPlayer(), event.getTo()); - }); + YamipaPlugin plugin = YamipaPlugin.getInstance(); + Bukkit.getScheduler().runTask(plugin, () -> onPlayerLocationChange(event.getPlayer(), event.getTo())); } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java index 5e34c33..5de2874 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java @@ -5,6 +5,7 @@ import io.josemmo.bukkit.plugin.storage.ImageFile; import io.josemmo.bukkit.plugin.utils.ActionBar; import io.josemmo.bukkit.plugin.utils.InteractWithEntityListener; +import io.josemmo.bukkit.plugin.utils.Logger; import org.bukkit.*; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; @@ -27,11 +28,19 @@ import java.util.Objects; public class ItemService extends InteractWithEntityListener implements Listener { - private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); - private static final NamespacedKey NSK_FILENAME = new NamespacedKey(plugin, "filename"); - private static final NamespacedKey NSK_WIDTH = new NamespacedKey(plugin, "width"); - private static final NamespacedKey NSK_HEIGHT = new NamespacedKey(plugin, "height"); - private static final NamespacedKey NSK_FLAGS = new NamespacedKey(plugin, "flags"); + private static final Logger LOGGER = Logger.getLogger("ItemService"); + private static final NamespacedKey NSK_FILENAME; + private static final NamespacedKey NSK_WIDTH; + private static final NamespacedKey NSK_HEIGHT; + private static final NamespacedKey NSK_FLAGS; + + static { + YamipaPlugin plugin = YamipaPlugin.getInstance(); // Only used for getting namespace, reference will be freed + NSK_FILENAME = new NamespacedKey(plugin, "filename"); + NSK_WIDTH = new NamespacedKey(plugin, "width"); + NSK_HEIGHT = new NamespacedKey(plugin, "height"); + NSK_FLAGS = new NamespacedKey(plugin, "flags"); + } /** * Get image item @@ -48,9 +57,9 @@ public class ItemService extends InteractWithEntityListener implements Listener // Set metadata PersistentDataContainer itemData = itemMeta.getPersistentDataContainer(); - itemMeta.setDisplayName(image.getName() + ChatColor.AQUA + " (" + width + "x" + height + ")"); + itemMeta.setDisplayName(image.getFilename() + ChatColor.AQUA + " (" + width + "x" + height + ")"); itemMeta.setLore(Collections.singletonList("Yamipa image")); - itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getName()); + itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getFilename()); itemData.set(NSK_WIDTH, PersistentDataType.INTEGER, width); itemData.set(NSK_HEIGHT, PersistentDataType.INTEGER, height); itemData.set(NSK_FLAGS, PersistentDataType.INTEGER, flags); @@ -64,6 +73,7 @@ public class ItemService extends InteractWithEntityListener implements Listener */ public void start() { register(); + YamipaPlugin plugin = YamipaPlugin.getInstance(); plugin.getServer().getPluginManager().registerEvents(this, plugin); } @@ -132,14 +142,14 @@ public void onPlaceItem(@NotNull HangingPlaceEvent event) { return; } if (width == null || height == null || flags == null) { - plugin.warning(player + " tried to place corrupted image item (missing width/height/flags properties)"); + LOGGER.warning(player + " tried to place corrupted image item (missing width/height/flags properties)"); return; } // Validate filename ImageFile image = YamipaPlugin.getInstance().getStorage().get(filename); if (image == null) { - plugin.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)"); + LOGGER.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)"); ActionBar.send(player, ChatColor.RED + "Image file \"" + filename + "\" no longer exists"); return; } @@ -176,7 +186,7 @@ public void onPlaceItem(@NotNull HangingPlaceEvent event) { @Override public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) { - ImageRenderer renderer = plugin.getRenderer(); + ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer(); Location location = block.getLocation(); // Has the player clicked a removable placed image? @@ -207,9 +217,8 @@ public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull B ImageFile imageFile = Objects.requireNonNull(image.getFile()); ItemStack imageItem = getImageItem(imageFile, 1, image.getWidth(), image.getHeight(), image.getFlags()); Location dropLocation = location.clone().add(0.5, -0.5, 0.5).add(face.getDirection()); - Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> { - block.getWorld().dropItem(dropLocation, imageItem); - }); + YamipaPlugin plugin = YamipaPlugin.getInstance(); + Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> block.getWorld().dropItem(dropLocation, imageItem)); } return false; diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldAreaId.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldAreaId.java index ccaf88a..9c44a86 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldAreaId.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldAreaId.java @@ -61,7 +61,7 @@ public WorldAreaId(@NotNull World world, int x, int z) { /** * Get nearby world area IDs in view distance (plus this one) - * @return List of neighbors + * @return Array of neighbors */ public @NotNull WorldAreaId[] getNeighborhood() { // Get value from cache (if available) diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/CachedMapsFile.java b/src/main/java/io/josemmo/bukkit/plugin/storage/CachedMapsFile.java new file mode 100644 index 0000000..8207f14 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/storage/CachedMapsFile.java @@ -0,0 +1,341 @@ +package io.josemmo.bukkit.plugin.storage; + +import io.josemmo.bukkit.plugin.YamipaPlugin; +import io.josemmo.bukkit.plugin.renderer.FakeImage; +import io.josemmo.bukkit.plugin.renderer.FakeMap; +import io.josemmo.bukkit.plugin.utils.Logger; +import org.jetbrains.annotations.NotNull; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import javax.imageio.stream.ImageInputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +public class CachedMapsFile extends SynchronizedFile { + private static final String CACHE_EXT = "cache"; + private static final byte[] CACHE_SIGNATURE = new byte[] {0x59, 0x4d, 0x50}; // "YMP" + private static final int CACHE_VERSION = 1; + private static final Logger LOGGER = Logger.getLogger("CachedMapsFile"); + private final ImageFile imageFile; + private final int width; + private final int height; + private FakeMap[][][] maps; + private int delay; + + /** + * Create instance from image file + * @param imageFile Image file instance + * @param width Width in blocks + * @param height Height in blocks + * @return Cached maps instance + */ + public static @NotNull CachedMapsFile from(@NotNull ImageFile imageFile, int width, int height) { + Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath(); + Path path = cachePath.resolve(imageFile.getFilename() + "." + width + "-" + height + "." + CACHE_EXT); + return new CachedMapsFile(path, imageFile, width, height); + } + + /** + * Delete all cached maps files associated to an image file + * @param imageFile Image file instance + */ + public static void deleteAll(@NotNull ImageFile imageFile) { + String relativeFilename = imageFile.getFilename(); + Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath(); + File baseDirectory = cachePath.resolve(relativeFilename).getParent().toFile(); + String cachePattern = Pattern.quote(Paths.get(relativeFilename).getFileName().toString()) + + "\\.[0-9]+-[0-9]+\\." + CACHE_EXT; + + // Find cache files to delete + if (!baseDirectory.exists()) { + // Cache subdirectory does not exist, no need to delete files + return; + } + File[] files = baseDirectory.listFiles((__, item) -> item.matches(cachePattern)); + if (files == null) { + LOGGER.warning("An error occurred when listing cache files for image \"" + relativeFilename + "\""); + return; + } + + // Delete disk cache files + for (File file : files) { + if (!file.delete()) { + LOGGER.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\""); + } + } + } + + /** + * Class constructor + * @param path Path to cached maps file in disk + * @param imageFile Image file associated to these maps + * @param width Width in blocks + * @param height Height blocks + */ + private CachedMapsFile(@NotNull Path path, @NotNull ImageFile imageFile, int width, int height) { + super(path); + this.imageFile = imageFile; + this.width = width; + this.height = height; + load(); + } + + /** + * Get maps + * @return Tri-dimensional array of maps (column, row, step) + */ + public @NotNull FakeMap[][][] getMaps() { + return maps; + } + + /** + * Get delay between steps + * @return Delay in 50ms intervals or 0 if not applicable + */ + public int getDelay() { + return delay; + } + + /** + * Load maps + */ + private void load() { + // Try to load maps from disk + if (exists() && getLastModified() > imageFile.getLastModified()) { + try { + loadFromDisk(); + return; + } catch (IllegalArgumentException e) { + LOGGER.info("Cache file \"" + path + "\" is outdated and will be overwritten"); + } catch (Exception e) { + LOGGER.warning("Cache file \"" + path + "\" is corrupted", e); + } + } + + // Generate maps from image file + try { + generateFromImage(); + tryToWriteToDisk(); + return; + } catch (Exception e) { + LOGGER.severe("Failed to render image step(s) from file \"" + path + "\"", e); + } + + // Fallback to error matrix + maps = FakeMap.getErrorMatrix(width, height); + delay = 0; + } + + /** + * Load data from disk + * @throws IllegalArgumentException if cache file is outdated + * @throws IOException if cache file is corrupted + */ + private void loadFromDisk() throws IllegalArgumentException, IOException { + try (RandomAccessFile stream = read()) { + // Validate file signature + for (byte expectedByte : CACHE_SIGNATURE) { + if ((byte) stream.read() != expectedByte) { + throw new IllegalArgumentException("Invalid file signature"); + } + } + + // Validate version number + if ((byte) stream.read() != CACHE_VERSION) { + throw new IllegalArgumentException("Incompatible file format version"); + } + + // Get number of animation steps + int numOfSteps = stream.read() | (stream.read() << 8); + if (numOfSteps < 1 || numOfSteps > FakeImage.MAX_STEPS) { + throw new IOException("Invalid number of animation steps: " + numOfSteps); + } + + // Get delay between steps + int delay = 0; + if (numOfSteps > 1) { + delay = stream.read(); + if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) { + throw new IOException("Invalid delay between steps: " + delay); + } + } + + // Read pixels + FakeMap[][][] maps = new FakeMap[width][height][numOfSteps]; + for (int col=0; col renderedImages = new ArrayList<>(); + Map delays = new HashMap<>(); + try (ImageInputStream inputStream = ImageIO.createImageInputStream(imageFile.read())) { + ImageReader reader = ImageIO.getImageReaders(inputStream).next(); + reader.setInput(inputStream); + String format = reader.getFormatName().toLowerCase(); + + // Create temporary canvas + int originalWidth = reader.getWidth(0); + int originalHeight = reader.getHeight(0); + BufferedImage tmpImage = new BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_4BYTE_ABGR); + Graphics2D tmpGraphics = tmpImage.createGraphics(); + tmpGraphics.setBackground(new Color(0, 0, 0, 0)); + + // Create temporary scaled canvas (for resizing) + BufferedImage tmpScaledImage = new BufferedImage(widthInPixels, heightInPixels, BufferedImage.TYPE_INT_ARGB); + Graphics2D tmpScaledGraphics = tmpScaledImage.createGraphics(); + tmpScaledGraphics.setBackground(new Color(0, 0, 0, 0)); + + // Read images from file + for (int step=0; step (count == null) ? 1 : count + 1); + disposePrevious = controlExtensionNode.getAttribute("disposalMethod").startsWith("restore"); + } + } + } + + // Clear temporary canvases (if needed) + if (disposePrevious) { + tmpGraphics.clearRect(0, 0, originalWidth, originalHeight); + tmpScaledGraphics.clearRect(0, 0, widthInPixels, heightInPixels); + } + + // Paint step image over temporary canvas + BufferedImage image = reader.read(step); + tmpGraphics.drawImage(image, imageLeft, imageTop, null); + image.flush(); + + // Resize image and get pixels + tmpScaledGraphics.drawImage(tmpImage, 0, 0, widthInPixels, heightInPixels, null); + int[] rgbaPixels = ((DataBufferInt) tmpScaledImage.getRaster().getDataBuffer()).getData(); + + // Convert RGBA pixels to Minecraft color indexes + byte[] renderedImage = new byte[widthInPixels * heightInPixels]; + IntStream.range(0, rgbaPixels.length) + .parallel() + .forEach(pixelIndex -> renderedImage[pixelIndex] = FakeMap.pixelToIndex(rgbaPixels[pixelIndex])); + renderedImages.add(renderedImage); + } catch (IndexOutOfBoundsException __) { + // No more steps to read + break; + } + } + + // Free resources + reader.dispose(); + tmpGraphics.dispose(); + tmpImage.flush(); + tmpScaledGraphics.dispose(); + tmpScaledImage.flush(); + } + + // Get most occurring delay (mode) + int delay = 0; + if (renderedImages.size() > 1) { + delay = Collections.max(delays.entrySet(), Map.Entry.comparingByValue()).getKey(); + delay = Math.round(delay * 0.2f); // (delay * 10) / 50 + delay = Math.min(Math.max(delay, FakeImage.MIN_DELAY), FakeImage.MAX_DELAY); + } + + // Instantiate fake maps from image steps + FakeMap[][][] maps = new FakeMap[width][height][renderedImages.size()]; + IntStream.range(0, renderedImages.size()).forEach(i -> { + for (int col=0; col> 8) & 0xff); // Number of animation steps (second byte) + if (numOfSteps > 1) { + stream.write(delay); + } + + // Add pixels + //noinspection ForLoopReplaceableByForEach + for (int col=0; col locks = new ConcurrentHashMap<>(); - private final Map cache = new HashMap<>(); + private final Map cache = new HashMap<>(); private final Map> subscribers = new HashMap<>(); - private final String name; - private final String path; + private final String filename; + private @Nullable Dimension size; /** * Class constructor - * @param name Image file name - * @param path Path to image file + * @param filename Image filename + * @param path Path to image file */ - protected ImageFile(@NotNull String name, @NotNull String path) { - this.name = name; - this.path = path; + protected ImageFile(@NotNull String filename, @NotNull Path path) { + super(path); + this.filename = filename; } /** - * Get image file name - * @return Image file name + * Get image filename + * @return Image filename */ - public @NotNull String getName() { - return name; - } - - /** - * Get image reader - * @return Image reader instance - * @throws IOException if failed to get suitable image reader - */ - private @NotNull ImageReader getImageReader() throws IOException { - ImageInputStream inputStream = ImageIO.createImageInputStream(new File(path)); - ImageReader reader = ImageIO.getImageReaders(inputStream).next(); - reader.setInput(inputStream); - return reader; - } - - /** - * Render images using Minecraft palette - * @param width New width in pixels - * @param height New height in pixels - * @return Pair of bi-dimensional array of Minecraft images (step, pixel index) and delay between steps - * @throws IOException if failed to render images from file - */ - private @NotNull Pair renderImages(int width, int height) throws IOException { - ImageReader reader = getImageReader(); - String format = reader.getFormatName().toLowerCase(); - - // Create temporary canvas - int originalWidth = reader.getWidth(0); - int originalHeight = reader.getHeight(0); - BufferedImage tmpImage = new BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_4BYTE_ABGR); - Graphics2D tmpGraphics = tmpImage.createGraphics(); - tmpGraphics.setBackground(new Color(0, 0, 0, 0)); - - // Create temporary scaled canvas (for resizing) - BufferedImage tmpScaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - Graphics2D tmpScaledGraphics = tmpScaledImage.createGraphics(); - tmpScaledGraphics.setBackground(new Color(0, 0, 0, 0)); - - // Read images from file - List renderedImages = new ArrayList<>(); - Map delays = new HashMap<>(); - for (int step=0; step (count == null) ? 1 : count + 1); - disposePrevious = controlExtensionNode.getAttribute("disposalMethod").startsWith("restore"); - } - } - } - - // Clear temporary canvases (if needed) - if (disposePrevious) { - tmpGraphics.clearRect(0, 0, originalWidth, originalHeight); - tmpScaledGraphics.clearRect(0, 0, width, height); - } - - // Paint step image over temporary canvas - BufferedImage image = reader.read(step); - tmpGraphics.drawImage(image, imageLeft, imageTop, null); - image.flush(); - - // Resize image and get pixels - tmpScaledGraphics.drawImage(tmpImage, 0, 0, width, height, null); - int[] rgbaPixels = ((DataBufferInt) tmpScaledImage.getRaster().getDataBuffer()).getData(); - - // Convert RGBA pixels to Minecraft color indexes - byte[] renderedImage = new byte[width*height]; - IntStream.range(0, rgbaPixels.length).parallel().forEach(pixelIndex -> { - renderedImage[pixelIndex] = FakeMap.pixelToIndex(rgbaPixels[pixelIndex]); - }); - renderedImages.add(renderedImage); - } catch (IndexOutOfBoundsException __) { - // No more steps to read - break; - } - } - - // Get most occurring delay (mode) - int delay = 0; - if (renderedImages.size() > 1) { - delay = Collections.max(delays.entrySet(), Map.Entry.comparingByValue()).getKey(); - delay = Math.round(delay * 0.2f); // (delay * 10) / 50 - delay = Math.min(Math.max(delay, FakeImage.MIN_DELAY), FakeImage.MAX_DELAY); - } - - // Free resources - reader.dispose(); - tmpGraphics.dispose(); - tmpImage.flush(); - tmpScaledGraphics.dispose(); - tmpScaledImage.flush(); - - return new Pair<>(renderedImages.toArray(new byte[0][0]), delay); + public @NotNull String getFilename() { + return filename; } /** * Get original size in pixels * @return Dimension instance or NULL if not a valid image file */ - public @Nullable Dimension getSize() { - try { - ImageReader reader = getImageReader(); - Dimension dimension = new Dimension(reader.getWidth(0), reader.getHeight(0)); + public synchronized @Nullable Dimension getSize() { + if (size != null) { + return size; + } + try (ImageInputStream inputStream = ImageIO.createImageInputStream(read())) { + ImageReader reader = ImageIO.getImageReaders(inputStream).next(); + reader.setInput(inputStream); + size = new Dimension(reader.getWidth(0), reader.getHeight(0)); reader.dispose(); - return dimension; - } catch (Exception e) { + return size; + } catch (IOException | RuntimeException e) { return null; } } /** - * Get image last modified time - * @return Last modified time in milliseconds, zero in case of error - */ - public long getLastModified() { - try { - return Files.getLastModifiedTime(Paths.get(path)).toMillis(); - } catch (Exception __) { - return 0L; - } - } - - /** - * Get maps and subscribe to maps cache + * Get maps and subscribe to them * @param subscriber Fake image instance requesting the maps - * @return Fake maps container + * @return Cached maps */ - public @NotNull FakeMapsContainer getMapsAndSubscribe(@NotNull FakeImage subscriber) { + @Blocking + public @NotNull CachedMapsFile getMapsAndSubscribe(@NotNull FakeImage subscriber) { int width = subscriber.getWidth(); int height = subscriber.getHeight(); String cacheKey = width + "-" + height; @@ -207,191 +80,38 @@ public long getLastModified() { Lock lock = locks.computeIfAbsent(cacheKey, __ -> new ReentrantLock()); lock.lock(); - // Execute code - FakeMapsContainer container; - try { - container = getMapsAndSubscribe(subscriber, cacheKey, width, height); - } finally { - lock.unlock(); - } - - return container; - } - - /** - * Get maps and subscribe to maps cache (internal) - * @param subscriber Fake image instance requesting the maps - * @param cacheKey Maps cache key - * @param width Desired image width in pixels - * @param height Desired image height in pixels - * @return Fake maps container - */ - private @NotNull FakeMapsContainer getMapsAndSubscribe( - @NotNull FakeImage subscriber, - @NotNull String cacheKey, - int width, - int height - ) { - // Update subscribers for given cached maps - subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>()).add(subscriber); - - // Try to get maps from memory cache - if (cache.containsKey(cacheKey)) { - return cache.get(cacheKey); - } - - // Try to get maps from disk cache - String cacheFilename = name + "." + cacheKey + "." + CACHE_EXT; - File cacheFile = Paths.get(plugin.getStorage().getCachePath(), cacheFilename).toFile(); - if (cacheFile.isFile() && cacheFile.lastModified() >= getLastModified()) { - try { - FakeMapsContainer container = readMapsFromCacheFile(cacheFile, width, height); - cache.put(cacheKey, container); - return container; - } catch (IllegalArgumentException e) { - plugin.info("Cache file \"" + cacheFile.getAbsolutePath() + "\" is outdated and will be overwritten"); - } catch (Exception e) { - plugin.log(Level.WARNING, "Cache file \"" + cacheFile.getAbsolutePath() + "\" is corrupted", e); - } - } - - // Generate maps from original image - FakeMapsContainer container; - try { - int widthInPixels = width*FakeMap.DIMENSION; - int heightInPixels = height*FakeMap.DIMENSION; - Pair res = renderImages(widthInPixels, heightInPixels); - byte[][] images = res.getFirst(); - int delay = res.getSecond(); - - // Instantiate fake maps - FakeMap[][][] matrix = new FakeMap[width][height][images.length]; - IntStream.range(0, images.length).forEach(i -> { - for (int col=0; col FakeImage.MAX_STEPS) { - throw new IOException("Invalid number of animation steps: " + numOfSteps); - } - - // Get delay between steps - int delay = 0; - if (numOfSteps > 1) { - delay = stream.read(); - if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) { - throw new IOException("Invalid delay between steps: " + delay); - } - } - - // Read pixels - FakeMap[][][] maps = new FakeMap[width][height][numOfSteps]; - for (int col=0; col> 8) & 0xff); // Number of animation steps (second byte) - if (numOfSteps > 1) { - stream.write(container.getDelay()); - } - - // Add pixels - for (int col=0; col new HashSet<>()).add(subscriber); + lock.unlock(); + locks.remove(cacheKey); + return maps; } } /** * Unsubscribe from memory cache *

- * This method is called by FakeImage instances when they get invalidated by a WorldArea. - * By notifying their respective source ImageFile, the latter can clear cached maps from memory when no more - * FakeItemFrames are using them. + * This method is called by {@link FakeImage} instances when they get invalidated by a world area change. + * By notifying their respective source {@link ImageFile}, the latter can clear cached maps from memory when no + * more {@link FakeItemFrame}s are using them. * @param subscriber Fake image instance */ public synchronized void unsubscribe(@NotNull FakeImage subscriber) { String cacheKey = subscriber.getWidth() + "-" + subscriber.getHeight(); - if (!subscribers.containsKey(cacheKey)) return; + if (!subscribers.containsKey(cacheKey)) { + // Not subscribed to this image file + return; + } // Remove subscriber Set currentSubscribers = subscribers.get(cacheKey); @@ -401,31 +121,19 @@ public synchronized void unsubscribe(@NotNull FakeImage subscriber) { if (currentSubscribers.isEmpty()) { subscribers.remove(cacheKey); cache.remove(cacheKey); - plugin.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + name + ")"); + LOGGER.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + filename + ")"); } } /** * Invalidate cache *

- * Removes all references to pre-generated map sets. + * Removes all references to cached map instances. * This way, next time an image is requested to be rendered, maps will be regenerated. */ public synchronized void invalidate() { + size = null; cache.clear(); - - // Delete disk cache files - File[] files = Paths.get(plugin.getStorage().getCachePath()).toFile().listFiles((__, filename) -> { - return filename.matches(Pattern.quote(name) + "\\.[0-9]+-[0-9]+\\." + CACHE_EXT); - }); - if (files == null) { - plugin.warning("An error occurred when listing cache files for image \"" + name + "\""); - return; - } - for (File file : files) { - if (!file.delete()) { - plugin.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\""); - } - } + CachedMapsFile.deleteAll(this); } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java b/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java index 51bda79..e6085d4 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java +++ b/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java @@ -1,42 +1,56 @@ package io.josemmo.bukkit.plugin.storage; -import io.josemmo.bukkit.plugin.YamipaPlugin; -import org.bukkit.Bukkit; -import org.bukkit.scheduler.BukkitTask; +import com.sun.nio.file.ExtendedWatchEventModifier; +import io.josemmo.bukkit.plugin.utils.Logger; +import io.josemmo.bukkit.plugin.utils.Permissions; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; import java.nio.file.*; -import java.util.Objects; -import java.util.SortedMap; -import java.util.TreeMap; -import java.util.logging.Level; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +/** + * A service whose purpose is to keep track of all available image files in a given directory. + * It supports recursive storage (e.g., nested directories) and watches for file system changes in realtime. + *

+ * All files are indexed based on their filename. + * Due to recursion, filenames can contain forward slashes (i.e., "/") and act as relative paths to the base + * directory. + */ public class ImageStorage { - static public final long POLLING_INTERVAL = 20L * 5; // In server ticks - static private final YamipaPlugin plugin = YamipaPlugin.getInstance(); - private final String basePath; - private final String cachePath; - private final SortedMap cachedImages = new TreeMap<>(); - private BukkitTask task; - private WatchService watchService; + private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); + private static final Logger LOGGER = Logger.getLogger("ImageStorage"); + /** Map of registered files indexed by filename */ + private final SortedMap files = new TreeMap<>(); + private final Path basePath; + private final Path cachePath; + private final String allowedPaths; + private @Nullable WatchService watchService; + private @Nullable Thread watchServiceThread; /** * Class constructor - * @param basePath Path to directory containing the images - * @param cachePath Path to directory containing the cached image maps + * @param basePath Path to directory containing the images + * @param cachePath Path to directory containing the cached image maps + * @param allowedPaths Allowed paths pattern */ - public ImageStorage(@NotNull String basePath, @NotNull String cachePath) { + public ImageStorage(@NotNull Path basePath, @NotNull Path cachePath, @NotNull String allowedPaths) { this.basePath = basePath; this.cachePath = cachePath; + this.allowedPaths = allowedPaths; } /** * Get base path * @return Base path */ - public @NotNull String getBasePath() { + public @NotNull Path getBasePath() { return basePath; } @@ -44,84 +58,45 @@ public ImageStorage(@NotNull String basePath, @NotNull String cachePath) { * Get cache path * @return Cache path */ - public @NotNull String getCachePath() { + public @NotNull Path getCachePath() { return cachePath; } /** - * Start instance - * @throws SecurityException if failed to access filesystem - * @throws Exception if failed to start watch service + * Start service + * @throws IOException if failed to start watch service + * @throws RuntimeException if already running */ - public void start() throws Exception { - // Create directories if necessary - File directory = new File(basePath); - if (directory.mkdirs()) { - plugin.info("Created images directory as it did not exist"); - } - if (new File(cachePath).mkdirs()) { - plugin.info("Created cache directory as it did not exist"); + public void start() throws IOException, RuntimeException { + // Prevent initializing more than once + if (watchService != null || watchServiceThread != null) { + throw new RuntimeException("Service is already running"); } - // Do initial directory listing - for (File file : Objects.requireNonNull(directory.listFiles())) { - if (file.isDirectory()) continue; - String filename = file.getName(); - cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath())); + // Create base directories if necessary + if (basePath.toFile().mkdirs()) { + LOGGER.info("Created images directory as it did not exist"); + } + if (cachePath.toFile().mkdirs()) { + LOGGER.info("Created cache directory as it did not exist"); } - plugin.fine("Found " + cachedImages.size() + " file(s) in images directory"); - // Prepare watch service + // Start watching files watchService = FileSystems.getDefault().newWatchService(); - Path directoryPath = directory.toPath(); - directoryPath.register( - watchService, - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY - ); - - // Start watching for changes - task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> { - WatchKey watchKey = watchService.poll(); - if (watchKey == null) return; - - watchKey.pollEvents().forEach(event -> { - WatchEvent.Kind kind = event.kind(); - File file = directoryPath.resolve((Path) event.context()).toFile(); - if (file.isDirectory()) return; - - String filename = file.getName(); - synchronized (this) { - if (kind == StandardWatchEventKinds.ENTRY_DELETE) { - ImageFile imageFile = cachedImages.get(filename); - if (imageFile != null) { - imageFile.invalidate(); - cachedImages.remove(filename); - } - plugin.fine("Detected file deletion at " + filename); - } else if (cachedImages.containsKey(filename)) { - cachedImages.get(filename).invalidate(); - plugin.fine("Detected file update at " + filename); - } else { - cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath())); - plugin.fine("Detected file creation at " + filename); - } - } - }); - - watchKey.reset(); - }, POLLING_INTERVAL, POLLING_INTERVAL); - plugin.fine("Started watching for file changes in images directory"); + watchServiceThread = new WatcherThread(); + watchServiceThread.start(); + registerDirectory(basePath, true); + LOGGER.fine("Found " + files.size() + " file(s) in images directory"); } /** - * Stop instance + * Stop service */ public void stop() { - // Cancel async task - if (task != null) { - task.cancel(); + // Interrupt watch service thread + if (watchServiceThread != null) { + watchServiceThread.interrupt(); + watchServiceThread = null; } // Close watch service @@ -129,8 +104,9 @@ public void stop() { try { watchService.close(); } catch (IOException e) { - plugin.log(Level.WARNING, "Failed to close watch service", e); + LOGGER.warning("Failed to close watch service", e); } + watchService = null; } } @@ -139,15 +115,70 @@ public void stop() { * @return Number of images */ public synchronized int size() { - return cachedImages.size(); + return files.size(); } /** - * Get all image filenames - * @return Sorted array of image filenames + * Get image filenames + * @param sender Sender instance to filter only allowed images + * @return Allowed images */ - public synchronized @NotNull String[] getAllFilenames() { - return cachedImages.keySet().toArray(new String[0]); + public synchronized @NotNull List getFilenames(@NotNull CommandSender sender) { + List response = new ArrayList<>(); + for (String filename : files.keySet()) { + if (isPathAllowed(filename, sender)) { + response.add(filename); + } + } + return response; + } + + /** + * Is path allowed + * @param path Path instance + * @param sender Sender instance + * @return Whether sender is allowed to access path + */ + public boolean isPathAllowed(@NotNull Path path, @NotNull CommandSender sender) { + return isPathAllowed(getFilename(path), sender); + } + + /** + * Is path allowed + * @param path Path relative to {@link ImageStorage#basePath} + * @param sender Sender instance + * @return Whether sender is allowed to access path + */ + public boolean isPathAllowed(@NotNull String path, @NotNull CommandSender sender) { + // Find allowed paths pattern + String rawPattern = null; + if (sender instanceof Player) { + rawPattern = Permissions.getVariable("yamipa-allowed-paths", (Player) sender); + } + if (rawPattern == null) { + rawPattern = allowedPaths; + } + if (rawPattern.isEmpty()) { + return true; + } + + // Replace special tokens in pattern + if (sender instanceof Player) { + Player player = (Player) sender; + rawPattern = rawPattern.replaceAll("#player#", Matcher.quoteReplacement(Pattern.quote(player.getName()))); + rawPattern = rawPattern.replaceAll("#uuid#", player.getUniqueId().toString()); + } else { + rawPattern = rawPattern.replaceAll("#player#", ".+"); + rawPattern = rawPattern.replaceAll("#uuid#", ".+"); + } + + // Perform partial match against pattern + try { + return Pattern.compile(rawPattern).matcher(path).find(); + } catch (PatternSyntaxException __) { + LOGGER.warning("Invalid allowed paths pattern: " + rawPattern); + return false; + } } /** @@ -156,6 +187,176 @@ public synchronized int size() { * @return Image instance or NULL if not found */ public synchronized @Nullable ImageFile get(@NotNull String filename) { - return cachedImages.get(filename); + return files.get(filename); + } + + /** + * Register directory + * @param path Path to directory + * @param isBase Whether is base directory or not + */ + private synchronized void registerDirectory(@NotNull Path path, boolean isBase) { + // Validate path + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + LOGGER.warning("Cannot list files in \"" + path + "\" as it is not a valid directory"); + return; + } + + // Do initial directory listing + for (File child : Objects.requireNonNull(path.toFile().listFiles())) { + if (child.isDirectory()) { + registerDirectory(child.toPath(), false); + } else { + registerFile(child.toPath()); + } + } + + // Start watching for files changes + if (!IS_WINDOWS || isBase) { + try { + WatchEvent.Kind[] events = new WatchEvent.Kind[]{ + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + }; + WatchEvent.Modifier[] modifiers = IS_WINDOWS ? + new WatchEvent.Modifier[]{ExtendedWatchEventModifier.FILE_TREE} : + new WatchEvent.Modifier[0]; + path.register(Objects.requireNonNull(watchService), events, modifiers); + LOGGER.fine("Started watching directory at \"" + path + "\""); + } catch (IOException | NullPointerException e) { + LOGGER.severe("Failed to register directory", e); + } + } + } + + /** + * Register file + * @param path Path to file + */ + private synchronized void registerFile(@NotNull Path path) { + // Validate path + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + LOGGER.warning("Cannot register \"" + path + "\" as it is not a valid file"); + return; + } + + // Add file to map + String filename = getFilename(path); + ImageFile imageFile = new ImageFile(filename, path); + if (files.putIfAbsent(filename, imageFile) == null) { + LOGGER.fine("Registered file \"" + filename + "\""); + } + } + + /** + * Unregister directory + * @param filename Filename to directory + */ + private synchronized void unregisterDirectory(@NotNull String filename) { + boolean foundFirst = false; + Iterator> iter = files.entrySet().iterator(); + while (iter.hasNext()) { + String entryKey = iter.next().getKey(); + if (entryKey.startsWith(filename+"/")) { + foundFirst = true; + iter.remove(); + LOGGER.fine("Unregistered file \"" + entryKey + "\""); + } else if (foundFirst) { + // We can break early because set is alphabetically sorted by key + break; + } + } + } + + /** + * Unregister file + * @param filename Filename to file + */ + private synchronized void unregisterFile(@NotNull String filename) { + ImageFile imageFile = files.remove(filename); + if (imageFile != null) { + imageFile.invalidate(); + LOGGER.fine("Unregistered file \"" + filename + "\""); + } + } + + /** + * Invalidate file + * @param filename Filename to file + */ + private synchronized void invalidateFile(@NotNull String filename) { + ImageFile imageFile = files.get(filename); + if (imageFile != null) { + imageFile.invalidate(); + } + } + + /** + * Handle watch event + * @param path Path to file or directory + * @param kind Event kind + */ + private synchronized void handleWatchEvent(@NotNull Path path, WatchEvent.Kind kind) { + // Check whether file currently exists in file system (for CREATE and UPDATE events) + // or is registered in the file list (for DELETE event) + String filename = getFilename(path); + boolean isFile = path.toFile().isFile() || files.containsKey(filename); + + // Handle creation event + if (kind == StandardWatchEventKinds.ENTRY_CREATE) { + if (isFile) { + registerFile(path); + } else { + registerDirectory(path, false); + } + return; + } + + // Handle deletion event + if (kind == StandardWatchEventKinds.ENTRY_DELETE) { + if (isFile) { + unregisterFile(filename); + } else { + unregisterDirectory(filename); + } + return; + } + + // Handle modification event + if (kind == StandardWatchEventKinds.ENTRY_MODIFY && isFile) { + invalidateFile(filename); + } + } + + /** + * Get filename from path + * @param path Path to file + * @return Relative path used for indexing + */ + private @NotNull String getFilename(@NotNull Path path) { + return basePath.relativize(path).toString().replaceAll("\\\\", "/"); + } + + private class WatcherThread extends Thread { + @Override + public void run() { + try { + WatchKey key; + while ((key = Objects.requireNonNull(watchService).take()) != null) { + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + Path keyPath = (Path) key.watchable(); + Path path = keyPath.resolve((Path) event.context()); + handleWatchEvent(path, kind); + } + key.reset(); + } + } catch (ClosedWatchServiceException | InterruptedException __) { + // Silently ignore exception, this is expected when service shuts down + } catch (NullPointerException e) { + LOGGER.severe("Watch service was stopped before watcher thread", e); + } + } } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFile.java b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFile.java new file mode 100644 index 0000000..c679032 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFile.java @@ -0,0 +1,77 @@ +package io.josemmo.bukkit.plugin.storage; + +import org.jetbrains.annotations.NotNull; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * An instance of a file stored in a shared filesystem (e.g., a NFS drive). + * All operations are performed in a blocking way. + * That is, the instance will acquire a read or write lock before reading or modifying the file. + * If the file is already locked by another resource, it will wait until the lock is released + * while blocking the thread. + */ +public class SynchronizedFile { + protected final Path path; + + /** + * Class constructor + * @param path Path to file + */ + public SynchronizedFile(@NotNull Path path) { + this.path = path; + } + + /** + * Check file exists + * @return Whether file exists or not + */ + public boolean exists() { + return Files.exists(path); + } + + /** + * Get image last modified time + * @return Last modified time in milliseconds or 0 in case of error + */ + public long getLastModified() { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (Exception __) { + return 0L; + } + } + + /** + * Make directories + *

+ * Creates the directory containing this file and its parents if they don't exist. + */ + public void mkdirs() { + try { + Files.createDirectories(path.getParent()); + } catch (IOException __) { + // Silently ignore exception + } + } + + /** + * Get file reader + * @return Readable stream of the file + * @throws IOException if failed to acquire lock + */ + public RandomAccessFile read() throws IOException { + return new SynchronizedFileStream(path, true); + } + + /** + * Get file writer + * @return Writable stream of the file + * @throws IOException if failed to acquire lock + */ + public RandomAccessFile write() throws IOException { + return new SynchronizedFileStream(path, false); + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java new file mode 100644 index 0000000..7410d16 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java @@ -0,0 +1,26 @@ +package io.josemmo.bukkit.plugin.storage; + +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Path; + +/** + * Blocking file stream implementing {@link RandomAccessFile} for reading and writing data. + * It will lock the file for reading/writing when opened, and release the lock when closed. + */ +public class SynchronizedFileStream extends RandomAccessFile { + /** + * Class constructor + * @param path Path to file + * @param readOnly Whether to open stream in read-only mode + * @throws IOException if failed to acquire lock + */ + @Blocking + public SynchronizedFileStream(@NotNull Path path, boolean readOnly) throws IOException { + super(path.toFile(), readOnly ? "r" : "rw"); + //noinspection ResultOfMethodCallIgnored + getChannel().lock(0L, Long.MAX_VALUE, readOnly); + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java b/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java index d5fda77..b1f6c9c 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java @@ -7,9 +7,9 @@ import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; import org.jetbrains.annotations.NotNull; -import java.util.logging.Level; public class ActionBar { + private static final Logger LOGGER = Logger.getLogger("ActionBar"); private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); private final Player player; private String message; @@ -72,7 +72,7 @@ public ActionBar sendOnce() { try { ProtocolLibrary.getProtocolManager().sendServerPacket(player, actionBarPacket); } catch (Exception e) { - plugin.log(Level.SEVERE, "Failed to send ActionBar to " + player.getName(), e); + LOGGER.severe("Failed to send ActionBar to " + player.getName(), e); } return this; } diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java b/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java index 2c2e163..bd809f1 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java @@ -1,20 +1,24 @@ package io.josemmo.bukkit.plugin.utils; import org.jetbrains.annotations.NotNull; -import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Paths; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; +/** + * A class for parsing simple CSV files. + * It's used for loading and writing Yamipa's configuration file ("image.dat" by default) and does not + * support escaping of special characters, as this is not currently needed. + */ public class CsvConfiguration { public static final Charset CHARSET = StandardCharsets.UTF_8; - public static final String COLUMN_DELIMITER = "/"; + public static final String COLUMN_DELIMITER = "\t"; private final List data = new ArrayList<>(); /** @@ -38,14 +42,25 @@ public void addRow(@NotNull String[] row) { * @param path File path * @throws IOException if failed to read file */ - public void load(@NotNull String path) throws IOException { - Stream stream = Files.lines(Paths.get(path), CHARSET); - stream.forEach(line -> { - line = line.trim(); - if (!line.isEmpty()) { + public void load(@NotNull Path path) throws IOException { + try (Stream stream = Files.lines(path, CHARSET)) { + stream.forEach(line -> { + line = line.trim(); + + // Ignore empty lines + if (line.isEmpty()) { + return; + } + + // Migrate legacy format + if (!line.contains(COLUMN_DELIMITER)) { + line = line.replaceAll("/", COLUMN_DELIMITER); + } + + // Parse line addRow(line.split(COLUMN_DELIMITER)); - } - }); + }); + } } /** @@ -53,8 +68,8 @@ public void load(@NotNull String path) throws IOException { * @param path File path * @throws IOException if failed to write file */ - public void save(@NotNull String path) throws IOException { - try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(path), CHARSET)) { + public void save(@NotNull Path path) throws IOException { + try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(path), CHARSET)) { for (String[] row : getRows()) { writer.write(String.join(COLUMN_DELIMITER, row) + "\n"); } diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java b/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java index 753f545..5445262 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java @@ -14,10 +14,10 @@ import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import java.util.List; -import java.util.logging.Level; public abstract class InteractWithEntityListener implements PacketListener { public static final int MAX_BLOCK_DISTANCE = 5; // Server should only accept entities within a 4-block radius + private static final Logger LOGGER = Logger.getLogger("InteractWithEntityListener"); /** * On player attack listener @@ -91,7 +91,7 @@ public final void onPacketReceiving(@NotNull PacketEvent event) { allowEvent = onInteract(player, targetBlock, targetBlockFace); } } catch (Exception e) { - YamipaPlugin.getInstance().log(Level.SEVERE, "Failed to notify entity listener handler", e); + LOGGER.severe("Failed to notify entity listener handler", e); } // Cancel event (if needed) diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/Logger.java b/src/main/java/io/josemmo/bukkit/plugin/utils/Logger.java new file mode 100644 index 0000000..0621142 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/Logger.java @@ -0,0 +1,110 @@ +package io.josemmo.bukkit.plugin.utils; + +import io.josemmo.bukkit.plugin.YamipaPlugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.logging.Level; + +/** + * Custom logging class used by components from the plugin. + * It is instance agnostic, that is, the same logger instance will work regardless of whether the plugin + * instance has changed (for example, because of the plugin being restarted by a plugin manager). + */ +public class Logger { + private final @Nullable String name; + + /** + * Get logger instance + * @param name Logger name + * @return Logger instance + */ + public static @NotNull Logger getLogger(@NotNull String name) { + return new Logger(name); + } + + /** + * Get logger instance + * @return Logger instance + */ + public static @NotNull Logger getLogger() { + return new Logger(null); + } + + /** + * Class constructor + * @param name Optional logger name + */ + private Logger(@Nullable String name) { + this.name = name; + } + + /** + * Log message + * @param level Record level + * @param message Message + * @param e Optional throwable to log + */ + private void log(@NotNull Level level, @NotNull String message, @Nullable Throwable e) { + YamipaPlugin plugin = YamipaPlugin.getInstance(); + + // Handle verbose logging levels + if (level.intValue() < Level.INFO.intValue()) { + if (!plugin.isVerbose()) return; + level = Level.INFO; + } + + // Add logger name to message + if (name != null) { + message = "[" + name + "] " + message; + } + + // Proxy record to real logger + if (e == null) { + plugin.getLogger().log(level, message); + } else { + plugin.getLogger().log(level, message, e); + } + } + + /** + * Log severe message + * @param message Message + * @param e Throwable to log + */ + public void severe(@NotNull String message, @NotNull Throwable e) { + log(Level.SEVERE, message, e); + } + + /** + * Log warning message + * @param message Message + * @param e Throwable to log + */ + public void warning(@NotNull String message, @NotNull Throwable e) { + log(Level.WARNING, message, e); + } + + /** + * Log warning message + * @param message Message + */ + public void warning(@NotNull String message) { + log(Level.WARNING, message, null); + } + + /** + * Log info message + * @param message Message + */ + public void info(@NotNull String message) { + log(Level.INFO, message, null); + } + + /** + * Log fine message + * @param message Message + */ + public void fine(@NotNull String message) { + log(Level.FINE, message, null); + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java b/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java index 2f7bb9e..58a0741 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java @@ -16,6 +16,9 @@ import me.angeschossen.lands.api.land.LandWorld; import me.angeschossen.lands.api.player.LandPlayer; import me.ryanhamshire.GriefPrevention.GriefPrevention; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import org.anjocaido.groupmanager.GroupManager; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; @@ -24,15 +27,29 @@ import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.concurrent.Callable; -import java.util.logging.Level; public class Permissions { - @Nullable private static WorldGuard worldGuard = null; - @Nullable private static GriefPrevention griefPrevention = null; - @Nullable private static TownyAPI townyApi = null; - @Nullable private static LandsIntegration landsApi = null; + private static final Logger LOGGER = Logger.getLogger(); + private static @Nullable LuckPerms luckPerms; + private static @Nullable GroupManager groupManager; + private static @Nullable WorldGuard worldGuard; + private static @Nullable GriefPrevention griefPrevention; + private static @Nullable TownyAPI townyApi; + private static @Nullable LandsIntegration landsApi; static { + try { + luckPerms = LuckPermsProvider.get(); + } catch (NoClassDefFoundError | IllegalStateException __) { + // LuckPerms is not installed + } + + try { + groupManager = (GroupManager) YamipaPlugin.getInstance().getServer().getPluginManager().getPlugin("GroupManager"); + } catch (NoClassDefFoundError __) { + // GroupManager is not installed + } + try { worldGuard = WorldGuard.getInstance(); } catch (NoClassDefFoundError __) { @@ -58,6 +75,27 @@ public class Permissions { } } + /** + * Get player variable + * @param variable Variable name (key) + * @param player Player instance + * @return Variable value or NULL if not found + */ + public static @Nullable String getVariable(@NotNull String variable, @NotNull Player player) { + if (luckPerms != null) { + return luckPerms.getPlayerAdapter(Player.class).getUser(player).getCachedData().getMetaData() + .getMetaValue(variable); + } + + if (groupManager != null) { + String rawValue = groupManager.getWorldsHolder().getWorldPermissions(player) + .getPermissionString(player.getName(), variable); + return rawValue.isEmpty() ? null : rawValue; + } + + return null; + } + /** * Can build at this block * @param player Player instance @@ -124,7 +162,7 @@ private static boolean queryGriefPrevention(@NotNull Player player, @NotNull Loc canEditCallable.call() : Bukkit.getScheduler().callSyncMethod(plugin, canEditCallable).get(); } catch (Exception e) { - plugin.log(Level.SEVERE, "Failed to get player permissions from GriefPrevention", e); + LOGGER.severe("Failed to get player permissions from GriefPrevention", e); return false; } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java b/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java index d1fb601..b97e9f7 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java @@ -22,7 +22,7 @@ import java.util.function.BiConsumer; public class SelectBlockTask { - private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); + private static final Logger LOGGER = Logger.getLogger("SelectBlockTask"); private static final Map instances = new HashMap<>(); private static SelectBlockTaskListener listener = null; private final Player player; @@ -71,7 +71,7 @@ public void run(@NotNull String helpMessage) { if (listener == null) { listener = new SelectBlockTaskListener(); listener.register(); - plugin.fine("Created SelectBlockTaskListener singleton"); + LOGGER.fine("Created SelectBlockTaskListener singleton"); } // Start task @@ -94,7 +94,7 @@ public void cancel() { if (instances.isEmpty()) { listener.unregister(); listener = null; - plugin.fine("Destroyed SelectBlockTaskListener singleton"); + LOGGER.fine("Destroyed SelectBlockTaskListener singleton"); } } @@ -105,6 +105,7 @@ private static class SelectBlockTaskListener extends InteractWithEntityListener @Override public void register() { super.register(); + YamipaPlugin plugin = YamipaPlugin.getInstance(); plugin.getServer().getPluginManager().registerEvents(this, plugin); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index bb95eb3..70faeba 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,9 @@ api-version: 1.16 depend: [ProtocolLib] softdepend: - GriefPrevention + - GroupManager - Hyperverse + - LuckPerms - Multiverse-Core - My_Worlds - Towny