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);
+ }
+}