diff --git a/src/main/java/com/alttd/playerutils/PlayerUtils.java b/src/main/java/com/alttd/playerutils/PlayerUtils.java index 573fbd3..b66df1b 100644 --- a/src/main/java/com/alttd/playerutils/PlayerUtils.java +++ b/src/main/java/com/alttd/playerutils/PlayerUtils.java @@ -42,6 +42,8 @@ public final class PlayerUtils extends JavaPlugin { pluginManager.registerEvents(new LimitArmorStands(this), this); pluginManager.registerEvents(new BlockBlockUseEvent(), this); pluginManager.registerEvents(new PlayerJoin(this), this); + pluginManager.registerEvents(new BookWriteEvent(), this); + pluginManager.registerEvents(new BookByteChunkLimitListener(this), this); RotateBlockEvent rotateBlockEvent = new RotateBlockEvent(); pluginManager.registerEvents(rotateBlockEvent, this); diff --git a/src/main/java/com/alttd/playerutils/event_listeners/BookByteChunkLimitListener.java b/src/main/java/com/alttd/playerutils/event_listeners/BookByteChunkLimitListener.java new file mode 100644 index 0000000..9e27d31 --- /dev/null +++ b/src/main/java/com/alttd/playerutils/event_listeners/BookByteChunkLimitListener.java @@ -0,0 +1,285 @@ +package com.alttd.playerutils.event_listeners; + +import com.alttd.playerutils.PlayerUtils; +import com.alttd.playerutils.util.BookByteUtils; +import com.alttd.playerutils.util.ChunkBookByteTracker; +import lombok.extern.slf4j.Slf4j; +import org.bukkit.Chunk; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.ItemDespawnEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; + +import java.util.Objects; + +@Slf4j +@SuppressWarnings("ClassCanBeRecord") +public class BookByteChunkLimitListener implements Listener { + + private final PlayerUtils plugin; + private static final int CHUNK_CAP = BookByteUtils.MAX_BOOK_BYTES * 3; // 65k * 3 + + public BookByteChunkLimitListener(PlayerUtils plugin) { + this.plugin = plugin; + } + + // World item drops ------------------------------------------------- + @EventHandler + public void onPlayerDropItem(PlayerDropItemEvent event) { + Item item = event.getItemDrop(); + ItemStack stack = item.getItemStack(); + if (!BookByteUtils.isWrittenBook(stack)) { + return; + } + int bytes = BookByteUtils.computeBytes(stack); + if (bytes <= 0) { + return; + } + Chunk chunk = item.getLocation().getChunk(); + if (!ChunkBookByteTracker.tryAddBytes(chunk, bytes, CHUNK_CAP, plugin)) { + log.warn("Player {} [{}] tried to drop a book of {} bytes to a chunk that is already saturated", event.getPlayer().getName(), event.getPlayer().getUniqueId(), bytes); + event.setCancelled(true); + Player p = event.getPlayer(); + p.sendRichMessage("You can't drop this here."); + } + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + Item item = event.getItem(); + ItemStack stack = event.getItem().getItemStack(); + if (!BookByteUtils.isWrittenBook(stack)) { + return; + } + int bytes = BookByteUtils.computeBytes(stack); + if (bytes <= 0) { + return; + } + ChunkBookByteTracker.removeBytes(item.getLocation().getChunk(), bytes, plugin); + } + + @EventHandler + public void onItemDespawn(ItemDespawnEvent event) { + Item item = event.getEntity(); + ItemStack stack = item.getItemStack(); + if (!BookByteUtils.isWrittenBook(stack)) { + return; + } + int bytes = BookByteUtils.computeBytes(stack); + if (bytes <= 0) { + return; + } + ChunkBookByteTracker.removeBytes(item.getLocation().getChunk(), bytes, plugin); + } + + // Player death drops ------------------------------------------------- + @EventHandler + public void onPlayerDeath(PlayerDeathEvent event) { + if (event.getDrops().isEmpty()) { + return; + } + Chunk chunk = event.getEntity().getLocation().getChunk(); + int current = ChunkBookByteTracker.getBytes(chunk, plugin); + int available = CHUNK_CAP - current; + if (available <= 0) { + // remove all large books from drops + event.getDrops().removeIf(stack -> { + if (!BookByteUtils.isWrittenBook(stack)) { + return false; + } + int bytes = BookByteUtils.computeBytes(stack); + boolean isByteLimitExceeded = bytes > 0; + if (isByteLimitExceeded) { + log.warn("Player {} [{}] tried to drop a book of {} bytes by dying in a chunk that is already saturated", event.getEntity().getName(), event.getEntity().getUniqueId(), bytes); + } + return isByteLimitExceeded; // despawn + }); + return; + } + int usedFromDeath = 0; + for (int i = 0; i < event.getDrops().size(); i++) { + ItemStack stack = event.getDrops().get(i); + if (!BookByteUtils.isWrittenBook(stack)) { + continue; + } + int bytes = BookByteUtils.computeBytes(stack); + if (bytes <= 0) { + continue; + } + if (usedFromDeath + bytes > available) { + // remove excess + log.warn("Player {} [{}] tried to drop a book of {} bytes by dying in a chunk that would be saturated", event.getEntity().getName(), event.getEntity().getUniqueId(), bytes); + event.getDrops().set(i, null); + } else { + usedFromDeath += bytes; + } + } + event.getDrops().removeIf(Objects::isNull); + if (usedFromDeath > 0) { + ChunkBookByteTracker.tryAddBytes(chunk, usedFromDeath, CHUNK_CAP, plugin); // will fit by construction + } + } + + // Container interactions -------------------------------------------- + @SuppressWarnings("DuplicatedCode") + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + InventoryView view = event.getView(); + Inventory top = view.getTopInventory(); + boolean topIsContainer = event.getRawSlot() < top.getSize(); + + // Shift-click from bottom to top (adding to container) + if (!topIsContainer) { + if (!event.isShiftClick()) { + return; + } + ItemStack current = event.getCurrentItem(); + if (current == null) { + return; + } + if (!BookByteUtils.isWrittenBook(current)) { + return; + } + int addBytes = BookByteUtils.computeBytes(current); + if (addBytes <= 0) { + return; + } + Chunk chunk = getContainerChunk(top); + if (chunk == null) { + return; + } + if (!ChunkBookByteTracker.tryAddBytes(chunk, addBytes, CHUNK_CAP, plugin)) { + log.warn("Player {} [{}] tried to shift click a book of {} bytes into a container in a chunk that is already saturated", event.getWhoClicked().getName(), event.getWhoClicked().getUniqueId(), addBytes); + event.setCancelled(true); + sendCantStore(event.getWhoClicked()); + } + return; + } + + // Interactions within the top container inventory + Chunk chunk = getContainerChunk(top); + if (chunk == null) { + return; + } + + ItemStack current = event.getCurrentItem(); // item in the clicked slot (may be removed) + ItemStack cursor = event.getCursor(); // item on cursor (may be added) + + int removeBytes = (BookByteUtils.isWrittenBook(current)) ? BookByteUtils.computeBytes(current) : 0; + int addBytes = !cursor.getType().isAir() && BookByteUtils.isWrittenBook(cursor) ? BookByteUtils.computeBytes(cursor) : 0; + + // If shift-click from top to bottom: only removal occurs + if (event.isShiftClick()) { + if (removeBytes <= 0) { + return; + } + ChunkBookByteTracker.removeBytes(chunk, removeBytes, plugin); + return; + } + + // If nothing book-related, ignore + if (removeBytes <= 0 && addBytes <= 0) { + return; + } + + int currentTracked = ChunkBookByteTracker.getBytes(chunk, plugin); + int projected = currentTracked - removeBytes + addBytes; + if (projected > CHUNK_CAP) { + log.warn("Player {} [{}] tried to add a book of {} bytes to a chunk that would be saturated", event.getWhoClicked().getName(), event.getWhoClicked().getUniqueId(), addBytes); + event.setCancelled(true); + sendCantStore(event.getWhoClicked()); + return; + } + // Apply adjustments + ChunkBookByteTracker.setBytes(chunk, Math.max(0, projected), plugin); + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + // If any of the added slots are in the top inventory, block if needed + InventoryView view = event.getView(); + Inventory top = view.getTopInventory(); + ItemStack cursor = event.getOldCursor(); + if (cursor.getType().isAir()) { + return; + } + if (!BookByteUtils.isWrittenBook(cursor)) { + return; + } + int bytes = BookByteUtils.computeBytes(cursor); + if (bytes <= 0) { + return; + } + boolean affectsTop = event.getRawSlots().stream().anyMatch(slot -> slot < top.getSize()); + if (!affectsTop) { + return; + } + Chunk chunk = getContainerChunk(top); + if (chunk == null) { + return; + } + if (!ChunkBookByteTracker.tryAddBytes(chunk, bytes, CHUNK_CAP, plugin)) { + log.warn("Player {} [{}] tried to add a book of {} bytes to a chunk that is already saturated", event.getWhoClicked().getName(), event.getWhoClicked().getUniqueId(), bytes); + event.setCancelled(true); + sendCantStore(event.getWhoClicked()); + } + } + + @EventHandler + public void onInventoryMoveItem(InventoryMoveItemEvent event) { + // hopper or other automation + ItemStack stack = event.getItem(); + if (!BookByteUtils.isWrittenBook(stack)) { + return; + } + int bytes = BookByteUtils.computeBytes(stack); + if (bytes <= 0) { + return; + } + Inventory dest = event.getDestination(); + // Only enforce when destination is a block inventory in world + Chunk destChunk = InventoryChunkResolver.getInventoryChunk(dest); + if (destChunk == null) { + return; + } + if (!ChunkBookByteTracker.tryAddBytes(destChunk, bytes, CHUNK_CAP, plugin)) { + event.setCancelled(true); + return; + } + // will move; remove from source if it was tracked in a container within a chunk + Chunk srcChunk = InventoryChunkResolver.getInventoryChunk(event.getSource()); + if (srcChunk != null) { + ChunkBookByteTracker.removeBytes(srcChunk, bytes, plugin); + } + } + + private void sendCantStore(HumanEntity who) { + who.sendRichMessage("You can't store this here."); + } + + private Chunk getContainerChunk(Inventory inv) { + return InventoryChunkResolver.getInventoryChunk(inv); + } + + // Helper to resolve container inventory chunks + private static class InventoryChunkResolver { + static Chunk getInventoryChunk(Inventory inv) { + if (inv == null) { + return null; + } + if (inv.getHolder() instanceof org.bukkit.block.BlockState state) { + return state.getLocation().getChunk(); + } + return null; + } + } +} diff --git a/src/main/java/com/alttd/playerutils/event_listeners/BookWriteEvent.java b/src/main/java/com/alttd/playerutils/event_listeners/BookWriteEvent.java new file mode 100644 index 0000000..cc6844d --- /dev/null +++ b/src/main/java/com/alttd/playerutils/event_listeners/BookWriteEvent.java @@ -0,0 +1,35 @@ +package com.alttd.playerutils.event_listeners; + +import com.alttd.playerutils.util.BookByteUtils; +import lombok.extern.slf4j.Slf4j; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.inventory.meta.BookMeta; + +@Slf4j +public class BookWriteEvent implements Listener { + + private static final int WARN_BOOK_BYTES = 10_000; + + @EventHandler + public void onPlayerEditBook(PlayerEditBookEvent event) { + Player player = event.getPlayer(); + + if (player.hasPermission("playerutils.book-write.bypass")) { + return; + } + + BookMeta meta = event.getNewBookMeta(); + + int totalBytes = BookByteUtils.computeBytes(meta); + + if (totalBytes > BookByteUtils.MAX_BOOK_BYTES) { + log.warn("Player {} [{}] tried to write a book with {} bytes", player.getName(), player.getUniqueId(), totalBytes); + event.setCancelled(true); + } else if (totalBytes > WARN_BOOK_BYTES) { + log.warn("Player {} [{}] wrote a book with {} bytes", player.getName(), player.getUniqueId(), totalBytes); + } + } +} diff --git a/src/main/java/com/alttd/playerutils/event_listeners/GhastSpeedEvent.java b/src/main/java/com/alttd/playerutils/event_listeners/GhastSpeedEvent.java index 4417966..2c50596 100644 --- a/src/main/java/com/alttd/playerutils/event_listeners/GhastSpeedEvent.java +++ b/src/main/java/com/alttd/playerutils/event_listeners/GhastSpeedEvent.java @@ -1,6 +1,7 @@ package com.alttd.playerutils.event_listeners; import com.alttd.playerutils.data_objects.GHAST_SPEED; +import lombok.extern.slf4j.Slf4j; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; import org.bukkit.entity.Entity; @@ -10,15 +11,13 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDismountEvent; import org.bukkit.event.entity.EntityMountEvent; -import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.UUID; +@Slf4j public class GhastSpeedEvent implements Listener { - private static final org.slf4j.Logger log = LoggerFactory.getLogger(GhastSpeedEvent.class); - private final HashMap lastSetSpeed = new HashMap<>(); public GhastSpeedEvent() { diff --git a/src/main/java/com/alttd/playerutils/util/BookByteUtils.java b/src/main/java/com/alttd/playerutils/util/BookByteUtils.java new file mode 100644 index 0000000..9c6806d --- /dev/null +++ b/src/main/java/com/alttd/playerutils/util/BookByteUtils.java @@ -0,0 +1,70 @@ +package com.alttd.playerutils.util; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; + +import java.nio.charset.StandardCharsets; + +/** + * Utility to compute the UTF-8 byte size of a written book's contents. + */ +public final class BookByteUtils { + + private BookByteUtils() { + } + + // 65,000 bytes per book (below CoreProtect hard limit ~65,535) + public static final int MAX_BOOK_BYTES = 65_000; + + public static boolean isWrittenBook(ItemStack stack) { + if (stack == null) { + return false; + } + if (!(stack.getItemMeta() instanceof BookMeta meta)) { + return false; + } + return meta.hasAuthor() || meta.hasPages() || meta.hasTitle(); + } + + /** + * Compute the number of bytes used by the provided BookMeta. + */ + public static int computeBytes(BookMeta meta) { + if (meta == null) { + return 0; + } + int totalBytes = 0; + String title = meta.getTitle(); + if (title != null) { + totalBytes += title.getBytes(StandardCharsets.UTF_8).length; + } + for (Component page : meta.pages()) { + if (page == null) { + continue; + } + String pageString = MiniMessage.miniMessage().serialize(page); + if (pageString.isEmpty()) { + continue; + } + totalBytes += pageString.getBytes(StandardCharsets.UTF_8).length; + } + return totalBytes; + } + + /** + * Compute the number of bytes used by a book item stack. If the item is not a written book, returns 0. + */ + public static int computeBytes(ItemStack stack) { + if (stack == null) { + return 0; + } + if (!(stack.getItemMeta() instanceof BookMeta meta)) { + return 0; + } + // written books do not stack; still, be safe and multiply by amount if it ever changes + int perBook = computeBytes(meta); + return perBook * Math.max(1, stack.getAmount()); + } +} diff --git a/src/main/java/com/alttd/playerutils/util/ChunkBookByteTracker.java b/src/main/java/com/alttd/playerutils/util/ChunkBookByteTracker.java new file mode 100644 index 0000000..650e363 --- /dev/null +++ b/src/main/java/com/alttd/playerutils/util/ChunkBookByteTracker.java @@ -0,0 +1,43 @@ +package com.alttd.playerutils.util; + +import com.alttd.playerutils.PlayerUtils; +import org.bukkit.Chunk; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +public final class ChunkBookByteTracker { + + private static NamespacedKey BYTES_KEY(PlayerUtils plugin) { + return NamespacedKey.fromString("big_book_bytes", plugin); + } + + private ChunkBookByteTracker() {} + + public static int getBytes(Chunk chunk, PlayerUtils plugin) { + PersistentDataContainer pdc = chunk.getPersistentDataContainer(); + Integer bytes = pdc.get(BYTES_KEY(plugin), PersistentDataType.INTEGER); + return bytes == null ? 0 : bytes; + } + + public static void setBytes(Chunk chunk, int bytes, PlayerUtils plugin) { + PersistentDataContainer pdc = chunk.getPersistentDataContainer(); + pdc.set(BYTES_KEY(plugin), PersistentDataType.INTEGER, Math.max(0, bytes)); + } + + public static boolean tryAddBytes(Chunk chunk, int add, int cap, PlayerUtils plugin) { + int current = getBytes(chunk, plugin); + if ((long) current + add > cap) { + return false; + } + setBytes(chunk, current + add, plugin); + return true; + } + + public static void removeBytes(Chunk chunk, int remove, PlayerUtils plugin) { + int current = getBytes(chunk, plugin); + int newVal = current - remove; + if (newVal < 0) newVal = 0; + setBytes(chunk, newVal, plugin); + } +}