Add BookByteChunkLimitListener and BookWriteEvent to enforce book byte limits and prevent chunk saturation

This commit is contained in:
akastijn 2026-03-27 18:51:43 +01:00
parent bd7a46c283
commit e03c51198c
6 changed files with 437 additions and 3 deletions

View File

@ -42,6 +42,8 @@ public final class PlayerUtils extends JavaPlugin {
pluginManager.registerEvents(new LimitArmorStands(this), this); pluginManager.registerEvents(new LimitArmorStands(this), this);
pluginManager.registerEvents(new BlockBlockUseEvent(), this); pluginManager.registerEvents(new BlockBlockUseEvent(), this);
pluginManager.registerEvents(new PlayerJoin(this), this); pluginManager.registerEvents(new PlayerJoin(this), this);
pluginManager.registerEvents(new BookWriteEvent(), this);
pluginManager.registerEvents(new BookByteChunkLimitListener(this), this);
RotateBlockEvent rotateBlockEvent = new RotateBlockEvent(); RotateBlockEvent rotateBlockEvent = new RotateBlockEvent();
pluginManager.registerEvents(rotateBlockEvent, this); pluginManager.registerEvents(rotateBlockEvent, this);

View File

@ -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("<red>You can't drop this here.</red>");
}
}
@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("<red>You can't store this here.</red>");
}
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;
}
}
}

View File

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

View File

@ -1,6 +1,7 @@
package com.alttd.playerutils.event_listeners; package com.alttd.playerutils.event_listeners;
import com.alttd.playerutils.data_objects.GHAST_SPEED; import com.alttd.playerutils.data_objects.GHAST_SPEED;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.attribute.Attribute; import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
@ -10,15 +11,13 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDismountEvent; import org.bukkit.event.entity.EntityDismountEvent;
import org.bukkit.event.entity.EntityMountEvent; import org.bukkit.event.entity.EntityMountEvent;
import org.slf4j.LoggerFactory;
import java.util.HashMap; import java.util.HashMap;
import java.util.UUID; import java.util.UUID;
@Slf4j
public class GhastSpeedEvent implements Listener { public class GhastSpeedEvent implements Listener {
private static final org.slf4j.Logger log = LoggerFactory.getLogger(GhastSpeedEvent.class);
private final HashMap<UUID, GHAST_SPEED> lastSetSpeed = new HashMap<>(); private final HashMap<UUID, GHAST_SPEED> lastSetSpeed = new HashMap<>();
public GhastSpeedEvent() { public GhastSpeedEvent() {

View File

@ -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());
}
}

View File

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