diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ae7f0bc..73d3e33 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -81,7 +81,7 @@ jobs: VERSION: ${{ matrix.version }} run: | if [ $VERSION == "1.19.4" ]; then - url="https://ci.dmulloy2.net/job/ProtocolLib/lastStableBuild/artifact/target/ProtocolLib.jar" + url="https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/build/libs/ProtocolLib.jar" else url="https://github.com/dmulloy2/ProtocolLib/releases/download/4.8.0/ProtocolLib.jar" fi diff --git a/README.md b/README.md index 805c1e1..cb1877a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![bStats Servers](https://img.shields.io/bstats/servers/10243)](https://bstats.org/plugin/bukkit/Yamipa/10243) [![License](https://img.shields.io/github/license/josemmo/yamipa)](LICENSE) -Yamipa is an Spigot plugin that allows players to place images (even **animated**!) on any surface in your Minecraft server +Yamipa is a Spigot plugin that allows players to place images (even **animated**!) on any surface in your Minecraft server without having to install any local client mod. It is designed with performance and compatibility in mind, so even the most low-specs servers should be able to run it. @@ -24,8 +24,9 @@ Download the JAR file for the [latest release](https://github.com/josemmo/yamipa ### Requirements Before installing Yamipa make sure you meet the following requirements: -- CraftBukkit, Spigot or PaperMC v1.16 or higher -- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) v4.6.1 or higher +- CraftBukkit, Spigot or PaperMC 1.16 or higher +- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) v4.8.0 or higher + (latest [dev build](https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/) for 1.19.4) Here are the Minecraft distributions where Yamipa should be able to run: | Minecraft version | CraftBukkit | Spigot | PaperMC | @@ -132,6 +133,7 @@ The supported plugins are: - [WorldGuard](https://enginehub.org/worldguard/) - [GriefPrevention](https://www.spigotmc.org/resources/griefprevention.1884/) +- [Towny Advanced](https://townyadvanced.github.io/) ## Flags Images from this plugin have a set of boolean attributes called "flags" that modify its behavior. Possible values are: diff --git a/pom.xml b/pom.xml index acedb80..b88b449 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.josemmo.bukkit.plugin YamipaPlugin - 1.2.9 + 1.2.10 8 @@ -59,7 +59,7 @@ org.bstats bstats-bukkit - 3.0.1 + 3.0.2 com.sk89q.worldguard @@ -73,6 +73,12 @@ 16.18.1 provided + + com.github.TownyAdvanced + towny + 0.98.6.25 + provided + org.jetbrains annotations 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 73c712e..bdbd13d 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java @@ -212,7 +212,7 @@ public static boolean placeImage( // Make sure image can be placed for (Location loc : fakeImage.getAllLocations()) { - if (!Permissions.canEditBlock(player, loc)) { + if (!Permissions.canBuild(player, loc)) { ActionBar.send(player, ChatColor.RED + "You're not allowed to place an image here!"); return false; } @@ -260,7 +260,7 @@ public static void removeImage(@NotNull Player player) { public static boolean removeImage(@NotNull Player player, @NotNull FakeImage image) { // Check block permissions for (Location loc : image.getAllLocations()) { - if (!Permissions.canEditBlock(player, loc)) { + if (!Permissions.canDestroy(player, loc)) { ActionBar.send(player, ChatColor.RED + "You're not allowed to remove this image!"); return false; } @@ -299,7 +299,7 @@ public static void clearImages( Player senderAsPlayer = (Player) sender; images.removeIf(image -> { for (Location loc : image.getAllLocations()) { - if (!Permissions.canEditBlock(senderAsPlayer, loc)) { + if (!Permissions.canDestroy(senderAsPlayer, loc)) { return true; } } 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 789c37a..419e524 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeEntity.java @@ -1,11 +1,13 @@ package io.josemmo.bukkit.plugin.renderer; +import com.comphenix.protocol.PacketType; import com.comphenix.protocol.ProtocolLibrary; import com.comphenix.protocol.ProtocolManager; import com.comphenix.protocol.events.PacketContainer; import com.comphenix.protocol.injector.player.PlayerInjectionHandler; import com.comphenix.protocol.wrappers.WrappedDataWatcher; import io.josemmo.bukkit.plugin.YamipaPlugin; +import io.josemmo.bukkit.plugin.utils.Internals; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import java.lang.reflect.Field; @@ -94,6 +96,23 @@ protected static void tryToSendPacket(@NotNull Player player, @NotNull PacketCon } } + /** + * Try to send several packets + * @param player Player who will receive the packets + * @param packets Packets to send + */ + protected static void tryToSendPackets(@NotNull Player player, @NotNull Iterable packets) { + if (Internals.MINECRAFT_VERSION < 19.4f) { + for (PacketContainer packet : packets) { + tryToSendPacket(player, packet); + } + } else { + PacketContainer container = new PacketContainer(PacketType.Play.Server.BUNDLE); + container.getPacketBundles().write(0, packets); + tryToSendPacket(player, container); + } + } + /** * Try to run asynchronous task * @param callback Callback to execute 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 20000fb..7740de8 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java @@ -1,5 +1,6 @@ package io.josemmo.bukkit.plugin.renderer; +import com.comphenix.protocol.events.PacketContainer; import io.josemmo.bukkit.plugin.storage.ImageFile; import io.josemmo.bukkit.plugin.utils.DirectionUtils; import org.bukkit.Location; @@ -12,6 +13,7 @@ import org.jetbrains.annotations.Nullable; import java.awt.*; import java.util.*; +import java.util.List; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.BiFunction; @@ -429,11 +431,19 @@ public void spawn(@NotNull Player player) { * @param player Player instance */ private void spawnOnceLoaded(@NotNull Player player) { + String playerName = player.getName(); observingPlayers.add(player); + + // Prepare packets to send + List packets = new ArrayList<>(); for (FakeItemFrame frame : frames) { - frame.spawn(player); - frame.render(player, 0); + packets.add(frame.getSpawnPacket()); + packets.addAll(frame.getRenderPackets(player, 0)); + plugin.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName); } + + // Send packets + tryToSendPackets(player, packets); } /** @@ -459,9 +469,13 @@ public void destroy(@Nullable Player player) { if (frames != null) { Set targets = (player == null) ? observingPlayers : Collections.singleton(player); for (Player target : targets) { + String targetName = target.getName(); + List packets = new ArrayList<>(); for (FakeItemFrame frame : frames) { - frame.destroy(target); + packets.add(frame.getDestroyPacket()); + plugin.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName); } + tryToSendPackets(target, packets); } } @@ -521,9 +535,11 @@ private void nextStep() { currentStep = (currentStep + 1) % numOfSteps; try { for (Player player : observingPlayers) { + List packets = new ArrayList<>(); for (FakeItemFrame frame : frames) { - frame.render(player, currentStep); + packets.addAll(frame.getRenderPackets(player, currentStep)); } + tryToSendPackets(player, packets); } } catch (ConcurrentModificationException e) { // We can safely ignore this exception as it will just result 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 2c352fb..0a6f4ec 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java @@ -1,5 +1,6 @@ package io.josemmo.bukkit.plugin.renderer; +import com.comphenix.protocol.events.PacketContainer; import com.comphenix.protocol.utility.MinecraftReflection; import com.comphenix.protocol.wrappers.nbt.NbtCompound; import com.comphenix.protocol.wrappers.nbt.NbtFactory; @@ -15,6 +16,8 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class FakeItemFrame extends FakeEntity { @@ -67,10 +70,18 @@ public FakeItemFrame( } /** - * Spawn empty item frame in player's client - * @param player Player instance + * Get frame ID + * @return Frame ID */ - public void spawn(@NotNull Player player) { + public int getId() { + return id; + } + + /** + * Get entity spawn packet + * @return Spawn packet + */ + public @NotNull SpawnEntityPacket getSpawnPacket() { // Calculate frame position in relation to target block double x = location.getBlockX(); double y = location.getBlockY(); @@ -115,18 +126,23 @@ public void spawn(@NotNull Player player) { .setPosition(x, y, z) .setRotation(pitch, yaw) .setData(orientation); - tryToSendPacket(player, framePacket); - plugin.fine("Spawned FakeItemFrame#" + this.id + " for Player#" + player.getName()); + + return framePacket; } /** - * Send frame of animation to player - * @param player Player instance - * @param step Map step to send + * Get frame of animation packets + * @param player Player who is expected to receive packets (for caching reasons) + * @param step Map step */ - public void render(@NotNull Player player, int step) { - // Send map pixels - maps[step].sendPixels(player); + public @NotNull List getRenderPackets(@NotNull Player player, int step) { + List packets = new ArrayList<>(2); + + // Enqueue map pixels packet (if needed) + boolean mustSendPixels = maps[step].requestResend(player); + if (mustSendPixels) { + packets.add(maps[step].getPixelsPacket()); + } // Create and attach filled map ItemStack itemStack = MinecraftReflection.getBukkitItemStack(new ItemStack(Material.FILLED_MAP)); @@ -135,25 +151,24 @@ public void render(@NotNull Player player, int step) { NbtFactory.setItemTag(itemStack, itemStackNbt); // Build entity metadata packet - EntityMetadataPacket mapPacket = new EntityMetadataPacket(); - mapPacket.setId(id) + EntityMetadataPacket metadataPacket = new EntityMetadataPacket(); + metadataPacket.setId(id) .setInvisible(true) .setItem(itemStack) .setRotation(rotation) .build(); + packets.add(metadataPacket); - // Send animation status update - tryToSendPacket(player, mapPacket); + return packets; } /** - * Destroy item frame from player's client - * @param player Player instance + * Get destroy item frame packet + * @return Destroy packet */ - public void destroy(@NotNull Player player) { + public @NotNull DestroyEntityPacket getDestroyPacket() { DestroyEntityPacket destroyPacket = new DestroyEntityPacket(); destroyPacket.setId(id); - tryToSendPacket(player, destroyPacket); - plugin.fine("Destroyed FakeItemFrame#" + this.id + " for Player#" + player.getName()); + return destroyPacket; } } 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 fa00be7..a063bb8 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeMap.java @@ -120,30 +120,37 @@ public byte[] getPixels() { } /** - * Send map pixels to player - * @param player Player instance + * Request re-send of map pixels + * @param player Player who is expected to receive pixels + * @return Whether re-send authorization was granted or not */ - public void sendPixels(@NotNull Player player) { + public boolean requestResend(@NotNull Player player) { UUID uuid = player.getUniqueId(); long now = Instant.now().getEpochSecond(); - // Avoid re-sending pixels too frequently + // Has enough time passed since last re-send? long last = lastPlayerSendTime.getOrDefault(uuid, 0L); if ((now-last) <= RESEND_THRESHOLD && (player.getLastPlayed()/1000) < last) { - return; + return false; } - // Create map data packet + // Authorize re-send and update latest timestamp + lastPlayerSendTime.put(uuid, now); + plugin.fine("Granted sending pixels for FakeMap#" + id + " to Player#" + player.getName()); + return true; + } + + /** + * Get map pixels packet + * @return Map pixels packet + */ + public @NotNull MapDataPacket getPixelsPacket() { MapDataPacket mapDataPacket = new MapDataPacket(); mapDataPacket.setId(id) .setScale(0) // Fully zoomed-in .setLocked(true) .setArea(DIMENSION, DIMENSION, 0, 0) .setPixels(pixels); - - // Send packet - tryToSendPacket(player, mapDataPacket); - lastPlayerSendTime.put(uuid, now); - plugin.fine("Sent pixels for FakeMap#" + id + " to Player#" + player.getName()); + return mapDataPacket; } } 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 52a59c2..e1c1586 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/Permissions.java @@ -1,14 +1,20 @@ package io.josemmo.bukkit.plugin.utils; +import com.palmergames.bukkit.towny.TownyAPI; +import com.palmergames.bukkit.towny.object.TownyPermission; +import com.palmergames.bukkit.towny.utils.PlayerCacheUtil; import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldguard.LocalPlayer; import com.sk89q.worldguard.WorldGuard; import com.sk89q.worldguard.bukkit.WorldGuardPlugin; import com.sk89q.worldguard.internal.platform.WorldGuardPlatform; +import com.sk89q.worldguard.protection.flags.Flags; +import com.sk89q.worldguard.protection.flags.StateFlag; import io.josemmo.bukkit.plugin.YamipaPlugin; import me.ryanhamshire.GriefPrevention.GriefPrevention; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -18,6 +24,7 @@ public class Permissions { @Nullable private static WorldGuard worldGuard = null; @Nullable private static GriefPrevention griefPrevention = null; + @Nullable private static TownyAPI townyApi = null; static { try { @@ -31,44 +38,88 @@ public class Permissions { } catch (NoClassDefFoundError __) { // GriefPrevention is not installed } + + try { + townyApi = TownyAPI.getInstance(); + } catch (NoClassDefFoundError __) { + // Towny is not installed + } + } + + /** + * Can build at this block + * @param player Player instance + * @param location Block location + * @return Whether player can build or not + */ + public static boolean canBuild(@NotNull Player player, @NotNull Location location) { + return queryWorldGuard(player, location, true) + && queryGriefPrevention(player, location, true) + && queryTowny(player, location, true); + } + + /** + * Can destroy this block + * @param player Player instance + * @param location Block location + * @return Whether player can destroy or not + */ + public static boolean canDestroy(@NotNull Player player, @NotNull Location location) { + return queryWorldGuard(player, location, false) + && queryGriefPrevention(player, location, false) + && queryTowny(player, location, false); + } + + private static boolean queryWorldGuard(@NotNull Player player, @NotNull Location location, boolean isBuild) { + if (worldGuard == null) { + return true; + } + WorldGuardPlatform platform = worldGuard.getPlatform(); + LocalPlayer wrappedPlayer = WorldGuardPlugin.inst().wrapPlayer(player); + + // Grant if bypass permission is enabled + boolean hasBypass = platform.getSessionManager().hasBypass( + wrappedPlayer, + BukkitAdapter.adapt(location.getWorld()) + ); + if (hasBypass) { + return true; + } + + // Check permission (note "BUILD" flag must always be present) + StateFlag flag = isBuild ? Flags.BLOCK_PLACE : Flags.BLOCK_BREAK; + return platform.getRegionContainer().createQuery() + .testState(BukkitAdapter.adapt(location), wrappedPlayer, Flags.BUILD, flag); } - public static boolean canEditBlock(@NotNull Player player, @NotNull Location location) { - // Check WorldGuard flags - if (worldGuard != null) { - WorldGuardPlatform platform = worldGuard.getPlatform(); - LocalPlayer wrappedPlayer = WorldGuardPlugin.inst().wrapPlayer(player); - boolean canEdit = platform.getRegionContainer().createQuery().testBuild( - BukkitAdapter.adapt(location), - wrappedPlayer - ); - boolean canBypass = platform.getSessionManager().hasBypass( - wrappedPlayer, - BukkitAdapter.adapt(location.getWorld()) - ); - if (!canEdit && !canBypass) { - return false; - } + private static boolean queryGriefPrevention(@NotNull Player player, @NotNull Location location, boolean isBuild) { + if (griefPrevention == null) { + return true; } + YamipaPlugin plugin = YamipaPlugin.getInstance(); - // Check GriefPrevention permissions - if (griefPrevention != null) { - YamipaPlugin plugin = YamipaPlugin.getInstance(); - Callable canEditCallable = () -> griefPrevention.allowBuild(player, location) == null; - try { - Boolean canEdit = Bukkit.isPrimaryThread() ? - canEditCallable.call() : - Bukkit.getScheduler().callSyncMethod(plugin, canEditCallable).get(); - if (!canEdit) { - return false; - } - } catch (Exception e) { - plugin.log(Level.SEVERE, "Failed to get player permissions from GriefPrevention", e); - return false; - } + // Build callable depending on permission to check + Callable canEditCallable = isBuild ? + () -> griefPrevention.allowBuild(player, location) == null : + () -> griefPrevention.allowBreak(player, location.getBlock(), location) == null; + + // Check permission from primary thread + try { + return Bukkit.isPrimaryThread() ? + canEditCallable.call() : + Bukkit.getScheduler().callSyncMethod(plugin, canEditCallable).get(); + } catch (Exception e) { + plugin.log(Level.SEVERE, "Failed to get player permissions from GriefPrevention", e); + return false; } + } - // Passed all checks, player can edit this block - return true; + private static boolean queryTowny(@NotNull Player player, @NotNull Location location, boolean isBuild) { + if (townyApi == null) { + return true; + } + Material material = location.getBlock().getType(); + TownyPermission.ActionType type = isBuild ? TownyPermission.ActionType.BUILD : TownyPermission.ActionType.DESTROY; + return PlayerCacheUtil.getCachePermission(player, location, material, type); } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f100005..bb95eb3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,6 +8,7 @@ softdepend: - Hyperverse - Multiverse-Core - My_Worlds + - Towny - WorldGuard permissions: