From a35447f13632937bedf182095cb15066f6a07586 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 15:23:56 +0200 Subject: [PATCH 1/8] Add paginated ender chests --- .../com/eternalcode/core/EternalCoreApi.java | 3 + .../feature/enderchest/EnderchestService.java | 47 ++ .../enderchest/event/EnderchestOpenEvent.java | 58 +++ .../eternalcode/core/EternalCoreApiImpl.java | 6 + .../implementation/PluginConfiguration.java | 8 + .../feature/container/EnderchestCommand.java | 53 --- .../container/messages/ContainerMessages.java | 3 - .../messages/ENContainerMessages.java | 3 - .../messages/PLContainerMessages.java | 3 - .../core/feature/enderchest/Enderchest.java | 308 +++++++++++++ .../feature/enderchest/EnderchestCommand.java | 95 ++++ .../feature/enderchest/EnderchestConfig.java | 126 ++++++ .../enderchest/EnderchestInventory.java | 418 ++++++++++++++++++ .../feature/enderchest/EnderchestLayout.java | 86 ++++ .../feature/enderchest/EnderchestManager.java | 219 +++++++++ .../enderchest/EnderchestServiceImpl.java | 59 +++ .../EnderchestSessionController.java | 78 ++++ .../enderchest/EnderchestSettings.java | 50 +++ .../feature/enderchest/EnderchestWrite.java | 12 + .../core/feature/enderchest/PageContents.java | 16 + .../VanillaEnderchestController.java | 134 ++++++ .../VanillaEnderchestMigration.java | 130 ++++++ .../database/EnderchestPageTable.java | 49 ++ .../database/EnderchestRepository.java | 17 + .../database/EnderchestRepositoryOrmLite.java | 156 +++++++ .../enderchest/database/ItemCodec.java | 83 ++++ .../messages/ENEnderchestMessages.java | 37 ++ .../messages/EnderchestMessages.java | 24 + .../messages/PLEnderchestMessages.java | 37 ++ .../core/translation/Translation.java | 3 + .../implementation/ENTranslation.java | 4 + .../implementation/PLTranslation.java | 4 + 32 files changed, 2267 insertions(+), 62 deletions(-) create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestService.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/event/EnderchestOpenEvent.java delete mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/container/EnderchestCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestLayout.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestServiceImpl.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSessionController.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSettings.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestWrite.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/PageContents.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestController.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/ENEnderchestMessages.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/EnderchestMessages.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/PLEnderchestMessages.java diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java b/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java index 9c56892ce..3a88abcd4 100644 --- a/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java +++ b/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java @@ -1,6 +1,7 @@ package com.eternalcode.core; import com.eternalcode.core.feature.afk.AfkService; +import com.eternalcode.core.feature.enderchest.EnderchestService; import com.eternalcode.core.feature.catboy.CatboyService; import com.eternalcode.core.feature.home.HomeService; import com.eternalcode.core.feature.jail.JailService; @@ -17,6 +18,8 @@ public interface EternalCoreApi { CatboyService getCatboyService(); + EnderchestService getEnderchestService(); + IgnoreService getIgnoreService(); HomeService getHomeService(); diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestService.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestService.java new file mode 100644 index 000000000..8fba8d57c --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestService.java @@ -0,0 +1,47 @@ +package com.eternalcode.core.feature.enderchest; + +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +/** + * Paginated ender chests. When the feature is disabled the vanilla ender chest is used and only + * {@link #openEnderchest(Player)} does anything; when ender chests are blocked nothing opens at all. + */ +public interface EnderchestService { + + /** + * @return whether ender chests are blocked entirely, which overrides {@link #isVanillaEnderchestReplaced()} + */ + boolean areEnderchestsBlocked(); + + /** + * @return whether paginated ender chests are enabled in the configuration + */ + boolean isVanillaEnderchestReplaced(); + + /** + * Opens the player's own ender chest: the first page when the feature is enabled, the vanilla + * ender chest otherwise. Does nothing while ender chests are blocked. + * + * @param player the player to open the chest for + */ + void openEnderchest(Player player); + + /** + * Opens a page of the owner's ender chest to the viewer. The owner may be offline. The viewer is + * notified when the page is beyond the owner's reach. Does nothing when the feature is disabled or + * ender chests are blocked. + * + * @param viewer the player who sees and edits the page + * @param owner the player whose chest is opened, online or not + * @param page 1-based page number + */ + void openEnderchest(Player viewer, OfflinePlayer owner, int page); + + /** + * @param player the player to evaluate + * @return number of pages the player's permissions grant, at least 1; pages that already hold items + * stay reachable beyond this limit + */ + int getPageLimit(Player player); +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/event/EnderchestOpenEvent.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/event/EnderchestOpenEvent.java new file mode 100644 index 000000000..e9eb340da --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/enderchest/event/EnderchestOpenEvent.java @@ -0,0 +1,58 @@ +package com.eternalcode.core.feature.enderchest.event; + +import java.util.UUID; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Called right before a page of a paginated ender chest is shown to a viewer. + */ +public class EnderchestOpenEvent extends Event implements Cancellable { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final UUID viewerUniqueId; + private final UUID ownerUniqueId; + private final int page; + private boolean cancelled; + + public EnderchestOpenEvent(UUID viewerUniqueId, UUID ownerUniqueId, int page) { + super(false); + this.viewerUniqueId = viewerUniqueId; + this.ownerUniqueId = ownerUniqueId; + this.page = page; + } + + public UUID getViewerUniqueId() { + return this.viewerUniqueId; + } + + public UUID getOwnerUniqueId() { + return this.ownerUniqueId; + } + + public int getPage() { + return this.page; + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java index 4680437ba..03d080bf4 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java @@ -1,6 +1,7 @@ package com.eternalcode.core; import com.eternalcode.core.feature.afk.AfkService; +import com.eternalcode.core.feature.enderchest.EnderchestService; import com.eternalcode.core.feature.catboy.CatboyService; import com.eternalcode.core.feature.home.HomeService; import com.eternalcode.core.feature.ignore.IgnoreService; @@ -30,6 +31,11 @@ public CatboyService getCatboyService() { return this.dependencyProvider.getDependency(CatboyService.class); } + @Override + public EnderchestService getEnderchestService() { + return this.dependencyProvider.getDependency(EnderchestService.class); + } + @Override public IgnoreService getIgnoreService() { return this.dependencyProvider.getDependency(IgnoreService.class); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/configuration/implementation/PluginConfiguration.java b/eternalcore-core/src/main/java/com/eternalcode/core/configuration/implementation/PluginConfiguration.java index 716d0da8b..d32d6b9fb 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/configuration/implementation/PluginConfiguration.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/configuration/implementation/PluginConfiguration.java @@ -25,6 +25,8 @@ import com.eternalcode.core.feature.deathteleport.DeathTeleportSettings; import com.eternalcode.core.feature.enchant.EnchantConfig; import com.eternalcode.core.feature.enchant.EnchantSettings; +import com.eternalcode.core.feature.enderchest.EnderchestConfig; +import com.eternalcode.core.feature.enderchest.EnderchestSettings; import com.eternalcode.core.feature.give.GiveConfig; import com.eternalcode.core.feature.give.GiveSettings; import com.eternalcode.core.feature.helpop.HelpOpConfig; @@ -252,6 +254,12 @@ public static class Format extends OkaeriConfig { @Comment("# Automatically teleports players back to their death location after respawning") DeathTeleportConfig deathTeleport = new DeathTeleportConfig(); + @Bean(proxied = EnderchestSettings.class) + @Comment("") + @Comment("# Ender Chest Configuration") + @Comment("# Replaces the vanilla ender chest with a paginated one kept in the database") + EnderchestConfig enderchest = new EnderchestConfig(); + @Override public File getConfigFile(File dataFolder) { return new File(dataFolder, "config.yml"); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/EnderchestCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/EnderchestCommand.java deleted file mode 100644 index fa8b72a74..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/EnderchestCommand.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.eternalcode.core.feature.container; - -import com.eternalcode.annotations.scan.command.DescriptionDocs; -import com.eternalcode.core.injector.annotations.Inject; -import com.eternalcode.core.notice.NoticeService; -import dev.rollczi.litecommands.annotations.argument.Arg; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Sender; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -@Command(name = "enderchest", aliases = { "ec" }) -class EnderchestCommand { - - private final NoticeService noticeService; - - @Inject - EnderchestCommand(NoticeService noticeService) { - this.noticeService = noticeService; - } - - @Execute - @Permission("eternalcore.enderchest") - @DescriptionDocs(description = "Opens your enderchest") - void execute(@Sender Player player) { - this.openEnderChest(player); - } - - @Execute - @Permission("eternalcore.enderchest.other") - @DescriptionDocs(description = "Opens selected player's enderchest to him", arguments = "") - void execute(@Sender CommandSender commandSender, @Arg Player target) { - this.openEnderChest(target); - - this.noticeService.create() - .notice(translation -> translation.container().targetEnderchestOpened()) - .sender(commandSender) - .placeholder("{PLAYER}", target.getName()) - .send(); - } - - void openEnderChest(Player player) { - player.openInventory(player.getEnderChest()); - - this.noticeService.create() - .notice(translation -> translation.container().enderchestOpened()) - .player(player.getUniqueId()) - .send(); - } - -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ContainerMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ContainerMessages.java index 889d72f83..7839cd2fe 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ContainerMessages.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ContainerMessages.java @@ -10,9 +10,6 @@ public interface ContainerMessages { Notice cartographyOpened(); Notice targetCartographyOpened(); - Notice enderchestOpened(); - Notice targetEnderchestOpened(); - Notice grindstoneOpened(); Notice targetGrindstoneOpened(); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ENContainerMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ENContainerMessages.java index d1a9ee572..841dc78e5 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ENContainerMessages.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/ENContainerMessages.java @@ -15,9 +15,6 @@ public class ENContainerMessages extends OkaeriConfig implements ContainerMessag Notice cartographyOpened = Notice.chat("► Cartography table opened!"); Notice targetCartographyOpened = Notice.chat("► Opened a cartography table for {PLAYER}!"); - Notice enderchestOpened = Notice.chat("► Ender chest opened!"); - Notice targetEnderchestOpened = Notice.chat("► Opened an ender chest for {PLAYER}!"); - Notice grindstoneOpened = Notice.chat("► Grindstone opened!"); Notice targetGrindstoneOpened = Notice.chat("► Opened a grindstone for {PLAYER}!"); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/PLContainerMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/PLContainerMessages.java index f9fe82a3d..d070a94d2 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/PLContainerMessages.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/container/messages/PLContainerMessages.java @@ -15,9 +15,6 @@ public class PLContainerMessages extends OkaeriConfig implements ContainerMessag Notice cartographyOpened = Notice.chat("► Otworzono stół kartograficzny!"); Notice targetCartographyOpened = Notice.chat("► Otworzono stół kartograficzny dla {PLAYER}!"); - Notice enderchestOpened = Notice.chat("► Otworzono skrzynię kresu!"); - Notice targetEnderchestOpened = Notice.chat("► Otworzono skrzynię kresu dla {PLAYER}!"); - Notice grindstoneOpened = Notice.chat("► Otworzono szlifierkę!"); Notice targetGrindstoneOpened = Notice.chat("► Otworzono szlifierkę dla {PLAYER}!"); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java new file mode 100644 index 000000000..87e0b866b --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java @@ -0,0 +1,308 @@ +package com.eternalcode.core.feature.enderchest; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +class Enderchest { + + private static final int[] NO_SLOTS = new int[0]; + + private final UUID ownerUniqueId; + private final EnderchestLayout layout; + private final Set dirtyPages = new HashSet<>(); + + private String ownerName; + private UUID viewerUniqueId; + private ItemStack[] slots; + private boolean rewriteRequired; + private int viewers; + private CompletableFuture pendingWrite = CompletableFuture.completedFuture(null); + + Enderchest(UUID ownerUniqueId, String ownerName, EnderchestLayout layout, ItemStack[] slots, boolean rewriteRequired) { + this.ownerUniqueId = ownerUniqueId; + this.ownerName = ownerName; + this.layout = layout; + this.slots = slots; + this.rewriteRequired = rewriteRequired; + } + + static Enderchest fromPages(UUID ownerUniqueId, String ownerName, EnderchestLayout layout, List pages) { + List flatSlots = new ArrayList<>(); + boolean rewriteRequired = false; + int expectedPage = EnderchestLayout.FIRST_PAGE; + + for (PageContents page : pages) { + while (expectedPage < page.page()) { + addEmptySlots(flatSlots, layout.capacityOf(expectedPage)); + expectedPage++; + } + + ItemStack[] items = page.items(); + if (items.length != layout.capacityOf(page.page())) { + rewriteRequired = true; + } + + for (ItemStack item : items) { + flatSlots.add(nullIfEmpty(item)); + } + expectedPage++; + } + + addEmptySlots(flatSlots, layout.totalSlots(layout.pagesIn(flatSlots.size())) - flatSlots.size()); + return new Enderchest(ownerUniqueId, ownerName, layout, flatSlots.toArray(new ItemStack[0]), rewriteRequired); + } + + UUID getOwnerUniqueId() { + return this.ownerUniqueId; + } + + String getOwnerName() { + return this.ownerName; + } + + void setOwnerName(String ownerName) { + this.ownerName = ownerName; + } + + EnderchestLayout getLayout() { + return this.layout; + } + + ItemStack[] getPageContents(int page) { + int capacity = this.layout.capacityOf(page); + int offset = this.layout.offsetOf(page); + this.ensureSlots(offset + capacity); + + ItemStack[] contents = new ItemStack[capacity]; + for (int index = 0; index < capacity; index++) { + contents[index] = copyItem(this.slots[offset + index]); + } + + return contents; + } + + void updatePage(int page, ItemStack[] contents) { + int capacity = this.layout.capacityOf(page); + int offset = this.layout.offsetOf(page); + this.ensureSlots(offset + capacity); + + boolean changed = false; + for (int index = 0; index < capacity; index++) { + ItemStack item = index < contents.length ? nullIfEmpty(contents[index]) : null; + + if (Objects.equals(this.slots[offset + index], item)) { + continue; + } + + this.slots[offset + index] = copyItem(item); + changed = true; + } + + if (changed) { + this.markDirty(page); + } + } + + int[] insertItems(ItemStack[] items) { + int count = 0; + for (ItemStack candidate : items) { + if (nullIfEmpty(candidate) != null) { + count++; + } + } + + if (count == 0) { + return NO_SLOTS; + } + + int[] filled = new int[count]; + int index = 0; + + for (ItemStack candidate : items) { + ItemStack item = nullIfEmpty(candidate); + if (item == null) { + continue; + } + + int slot = this.firstFreeSlot(); + this.slots[slot] = item.clone(); + this.markDirty(this.layout.pageOf(slot)); + filled[index++] = slot; + } + + return filled; + } + + boolean hasAlreadyImported(ItemStack[] items) { + List importedItems = new ArrayList<>(); + + for (ItemStack candidate : items) { + ItemStack item = nullIfEmpty(candidate); + if (item != null) { + importedItems.add(item); + } + } + + int tailStart = this.lastOccupiedSlot() - importedItems.size() + 1; + if (importedItems.isEmpty() || tailStart < 0) { + return false; + } + + for (int index = 0; index < importedItems.size(); index++) { + if (!importedItems.get(index).equals(this.slots[tailStart + index])) { + return false; + } + } + + return true; + } + + void clearSlots(int[] targets) { + for (int slot : targets) { + if (slot >= this.slots.length || this.slots[slot] == null) { + continue; + } + + this.slots[slot] = null; + this.markDirty(this.layout.pageOf(slot)); + } + } + + private int lastOccupiedSlot() { + for (int slot = this.slots.length - 1; slot >= 0; slot--) { + if (this.slots[slot] != null) { + return slot; + } + } + + return -1; + } + + int getUsedPages() { + for (int slot = this.slots.length - 1; slot >= 0; slot--) { + if (this.slots[slot] != null) { + return this.layout.pageOf(slot); + } + } + + return 0; + } + + EnderchestWrite prepareWrite() { + if (this.hasNothingToWrite()) { + return EnderchestWrite.NONE; + } + + boolean replaceAll = this.rewriteRequired; + int pageCount = this.layout.pagesIn(this.slots.length); + List pages = new ArrayList<>(); + + for (int page = 1; page <= pageCount; page++) { + if (replaceAll || this.dirtyPages.contains(page)) { + pages.add(new PageContents(page, this.getPageContents(page))); + } + } + + this.dirtyPages.clear(); + this.rewriteRequired = false; + this.pendingWrite = new CompletableFuture<>(); + return new EnderchestWrite(pages, replaceAll); + } + + void restoreDirtyPages(EnderchestWrite write) { + this.rewriteRequired |= write.replaceAll(); + + for (PageContents page : write.pages()) { + this.dirtyPages.add(page.page()); + } + } + + void attachViewer(UUID viewerUniqueId) { + this.viewerUniqueId = viewerUniqueId; + this.viewers++; + } + + void detachViewer() { + this.viewers = Math.max(0, this.viewers - 1); + + if (this.viewers == 0) { + this.viewerUniqueId = null; + } + } + + boolean isViewedByOther(UUID viewerUniqueId) { + return this.viewers > 0 && !viewerUniqueId.equals(this.viewerUniqueId); + } + + boolean hasViewers() { + return this.viewers > 0; + } + + boolean hasNothingToWrite() { + return !this.rewriteRequired && this.dirtyPages.isEmpty(); + } + + boolean isPersisted() { + return !this.rewriteRequired && this.dirtyPages.isEmpty() && this.pendingWrite.isDone(); + } + + boolean isWriting() { + return !this.pendingWrite.isDone(); + } + + void finishWrite() { + this.pendingWrite.complete(null); + } + + private void markDirty(int page) { + this.dirtyPages.add(page); + } + + private int firstFreeSlot() { + for (int slot = 0; slot < this.slots.length; slot++) { + if (this.slots[slot] == null) { + return slot; + } + } + + int slot = this.slots.length; + this.ensureSlots(slot + 1); + return slot; + } + + private void ensureSlots(int required) { + if (this.slots.length >= required) { + return; + } + + ItemStack[] grownSlots = new ItemStack[this.layout.totalSlots(this.layout.pagesIn(required))]; + System.arraycopy(this.slots, 0, grownSlots, 0, this.slots.length); + this.slots = grownSlots; + } + + private static void addEmptySlots(List target, int amount) { + for (int index = 0; index < amount; index++) { + target.add(null); + } + } + + private static ItemStack nullIfEmpty(ItemStack item) { + if (item == null || item.getAmount() <= 0) { + return null; + } + + Material type = item.getType(); + return type == Material.AIR || type == Material.CAVE_AIR || type == Material.VOID_AIR ? null : item; + } + + private static ItemStack copyItem(ItemStack item) { + return item == null ? null : item.clone(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java new file mode 100644 index 000000000..a77c27f83 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java @@ -0,0 +1,95 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.notice.NoticeService; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.optional.OptionalArg; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +@Command(name = "enderchest", aliases = { "ec" }) +class EnderchestCommand { + + private final EnderchestService enderchestService; + private final NoticeService noticeService; + + @Inject + EnderchestCommand(EnderchestService enderchestService, NoticeService noticeService) { + this.enderchestService = enderchestService; + this.noticeService = noticeService; + } + + @Execute + @Permission("eternalcore.enderchest") + @DescriptionDocs(description = "Opens your ender chest") + void execute(@Sender Player player) { + if (this.enderchestService.areEnderchestsBlocked()) { + this.sendBlockedNotice(player); + return; + } + + this.enderchestService.openEnderchest(player); + + if (this.enderchestService.isVanillaEnderchestReplaced()) { + return; + } + + this.noticeService.create() + .notice(translation -> translation.enderchest().openedEnderchest()) + .player(player.getUniqueId()) + .send(); + } + + @Execute + @Permission("eternalcore.enderchest") + @DescriptionDocs(description = "Opens selected page of your ender chest", arguments = "") + void execute(@Sender Player player, @Arg int page) { + if (this.enderchestService.areEnderchestsBlocked()) { + this.sendBlockedNotice(player); + return; + } + + if (!this.enderchestService.isVanillaEnderchestReplaced()) { + this.sendCustomEnderchestDisabledNotice(player); + return; + } + + this.enderchestService.openEnderchest(player, player, page); + } + + @Execute + @Permission("eternalcore.enderchest.other") + @DescriptionDocs(description = "Opens the ender chest of selected player (also offline)", arguments = " [page]") + void executeOther(@Sender Player viewer, @Arg OfflinePlayer target, @OptionalArg Integer page) { + if (this.enderchestService.areEnderchestsBlocked()) { + this.sendBlockedNotice(viewer); + return; + } + + if (!this.enderchestService.isVanillaEnderchestReplaced()) { + this.sendCustomEnderchestDisabledNotice(viewer); + return; + } + + this.enderchestService.openEnderchest(viewer, target, page == null ? EnderchestLayout.FIRST_PAGE : page); + } + + private void sendBlockedNotice(Player player) { + this.noticeService.create() + .notice(translation -> translation.enderchest().enderchestsBlocked()) + .player(player.getUniqueId()) + .send(); + } + + private void sendCustomEnderchestDisabledNotice(Player player) { + this.noticeService.create() + .notice(translation -> translation.enderchest().customEnderchestDisabled()) + .player(player.getUniqueId()) + .send(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java new file mode 100644 index 000000000..3dd4ab1e0 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java @@ -0,0 +1,126 @@ +package com.eternalcode.core.feature.enderchest; + +import com.cryptomorin.xseries.XMaterial; +import com.eternalcode.core.util.MaterialUtil; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.bukkit.Material; + +@Getter +@Accessors(fluent = true) +public class EnderchestConfig extends OkaeriConfig implements EnderchestSettings { + + @Comment({ + "# Replace the vanilla ender chest with the one from EternalCore, kept in the database", + "#", + "# This is the switch for the whole feature, not only for the extra pages. It turns on the custom", + "# inventory, the page limits per permission and everything in the 'pages' section below.", + "# With it off nothing else here does anything and players use their plain vanilla ender chest.", + "#", + "# WARNING: Make a backup of your database before you turn this on.", + "# After the next restart or /eternalcore reload everything from the vanilla ender chests is moved", + "# into the database and the vanilla ones are emptied. This cannot be undone.", + "# Online players are migrated right away, everyone else when they join the server.", + "#", + "# Turning it off later leaves the items in the database and players go back to the vanilla chest." + }) + public boolean replaceVanillaEnderchest = false; + + @Comment("# Take ender chests away from everyone, vanilla and custom alike") + public boolean enderchestsBlocked = false; + + @Comment({ + "# Block more than one player from having the same ender chest open at once", + "# Opening one that somebody else is already viewing closes it for them and has to be repeated", + "#", + "# Keep this on when you run Folia, two viewers from different regions share one inventory there,", + "# which the server does not guard and which can duplicate or destroy items" + }) + public boolean sharedViewingBlocked = true; + + public PageConfig pages = new PageConfig(); + + @Getter + @Accessors(fluent = true) + public static class PageConfig extends OkaeriConfig implements PageSettings { + + @Comment({ "# Number of inventory rows on a single page (1-6)", + "# The last row holds the navigation items, so a page with 3 rows stores 26 items" }) + public int rows = 3; + + @Comment("# {PLAYER} - Chest owner, {PAGE} - Current page, {PAGES} - Available pages") + public String title = "» Ender chest ({PAGE}/{PAGES})"; + + @Comment({ "# How long a player has to wait between page switches, set to 0s to turn it off", + "# It only limits the navigation items, opening a page with a command is not affected" }) + public Duration switchDelay = Duration.ofSeconds(3); + + @Comment("# How many pages a player gets without any permission from the list below") + public int defaultLimit = 1; + + @Comment({ "# How many pages a permission grants, 'permission : pages'", + "# A player with several of them gets the highest number, they do not add up", + "# Pages that already hold items stay reachable even after the permission is gone" }) + public Map limits = Map.of( + "eternalcore.enderchest.vip", 2, + "eternalcore.enderchest.premium", 4 + ); + + @Comment("# Items of the navigation row") + public NavigationConfig navigation = new NavigationConfig(); + } + + @Getter + @Accessors(fluent = true) + public static class NavigationConfig extends OkaeriConfig implements NavigationSettings { + + @Comment({ "# Opens the next page, always placed in the last slot of the inventory", + "# {PAGE} - Current page, {PAGES} - Available pages, {NEXT} - Next page, {PREVIOUS} - Previous page, {PLAYER} - Chest owner" }) + public ItemConfig nextPage = new ItemConfig( + MaterialUtil.parseRequired(XMaterial.ARROW), + "► Next page", + List.of("» Click to open page {NEXT}"), + false + ); + + @Comment({ "# Opens the previous page, placed in the first slot of the last row", + "# Shown from the second page on, the first page uses that slot for items" }) + public ItemConfig previousPage = new ItemConfig( + MaterialUtil.parseRequired(XMaterial.ARROW), + "◄ Previous page", + List.of("» Click to open page {PREVIOUS}"), + false + ); + } + + @Getter + @Accessors(fluent = true) + public static class ItemConfig extends OkaeriConfig implements ItemSettings { + + @Comment("# Material of the item") + public Material material = MaterialUtil.parseRequired(XMaterial.ARROW); + + @Comment("# Display name, leave empty to keep the material's own name") + public String name = ""; + + @Comment("# Lore lines") + public List lore = List.of(); + + @Comment("# Should the item glow?") + public boolean glow = false; + + public ItemConfig() {} + + public ItemConfig(Material material, String name, List lore, boolean glow) { + this.material = material; + this.name = name; + this.lore = lore; + this.glow = glow; + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java new file mode 100644 index 000000000..664a90488 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java @@ -0,0 +1,418 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.commons.adventure.AdventureUtil; +import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.core.delay.Delay; +import com.eternalcode.core.event.EventCaller; +import com.eternalcode.core.feature.enderchest.event.EnderchestOpenEvent; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.core.notice.NoticeService; +import com.eternalcode.core.util.DurationUtil; +import dev.triumphteam.gui.builder.item.ItemBuilder; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.StorageGui; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +@Service +class EnderchestInventory { + + private static final String PLAYER_PLACEHOLDER = "{PLAYER}"; + private static final String PAGE_PLACEHOLDER = "{PAGE}"; + private static final String PAGES_PLACEHOLDER = "{PAGES}"; + private static final String NEXT_PLACEHOLDER = "{NEXT}"; + private static final String PREVIOUS_PLACEHOLDER = "{PREVIOUS}"; + private static final String TIME_PLACEHOLDER = "{TIME}"; + + private final Map openPages = new ConcurrentHashMap<>(); + private final Delay pageSwitchDelay; + + private final EnderchestManager enderchestManager; + private final EnderchestSettings settings; + private final NoticeService noticeService; + private final MiniMessage miniMessage; + private final MinecraftScheduler scheduler; + private final EventCaller eventCaller; + + @Inject + EnderchestInventory( + EnderchestManager enderchestManager, + EnderchestSettings settings, + NoticeService noticeService, + MiniMessage miniMessage, + MinecraftScheduler scheduler, + EventCaller eventCaller + ) { + this.enderchestManager = enderchestManager; + this.settings = settings; + this.noticeService = noticeService; + this.miniMessage = miniMessage; + this.scheduler = scheduler; + this.eventCaller = eventCaller; + this.pageSwitchDelay = Delay.withDefault(() -> settings.pages().switchDelay()); + } + + void openPage(Player viewer, OfflinePlayer owner, int page) { + this.loadAndOpenPage(viewer, owner.getUniqueId(), owner.getName(), page); + } + + void saveOpenPages() { + this.openPages.forEach((key, session) -> { + Enderchest enderchest = session.enderchest(); + + updatePageFromInventory(key, enderchest, session.gui().getInventory()); + this.enderchestManager.saveEnderchest(enderchest); + }); + } + + boolean isOpenPage(Inventory inventory) { + for (PageSession session : this.openPages.values()) { + if (session.gui().getInventory().equals(inventory)) { + return true; + } + } + + return false; + } + + void closeAllPages() { + for (PageSession session : new ArrayList<>(this.openPages.values())) { + for (HumanEntity viewer : new ArrayList<>(session.gui().getInventory().getViewers())) { + viewer.closeInventory(); + } + } + } + + private void loadAndOpenPage(Player viewer, UUID ownerUniqueId, @Nullable String ownerName, int page) { + this.enderchestManager.loadEnderchest(ownerUniqueId, ownerName) + .thenAccept(enderchest -> this.scheduler.run(viewer, () -> this.openLoadedPage(viewer, enderchest, page))) + .exceptionally(FutureHandler::handleException); + } + + private void openLoadedPage(Player viewer, Enderchest enderchest, int page) { + UUID ownerUniqueId = enderchest.getOwnerUniqueId(); + + if (this.settings.enderchestsBlocked() || !this.settings.replaceVanillaEnderchest()) { + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + if (!this.enderchestManager.isLoaded(ownerUniqueId, enderchest)) { + this.loadAndOpenPage(viewer, ownerUniqueId, null, page); + return; + } + + if (!viewer.getUniqueId().equals(ownerUniqueId) && enderchest.getUsedPages() == 0) { + this.sendEnderchestEmptyNotice(viewer, enderchest); + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + this.showPage(viewer, enderchest, page); + } + + private void showPage(Player viewer, Enderchest enderchest, int page) { + UUID ownerUniqueId = enderchest.getOwnerUniqueId(); + int pages = this.enderchestManager.getAccessiblePages(enderchest); + + if (page < EnderchestLayout.FIRST_PAGE || page > pages) { + this.sendPageUnavailableNotice(viewer, page, pages); + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + EnderchestOpenEvent event = this.eventCaller.callEvent(new EnderchestOpenEvent(viewer.getUniqueId(), ownerUniqueId, page)); + if (event.isCancelled()) { + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + if (this.settings.sharedViewingBlocked() && enderchest.isViewedByOther(viewer.getUniqueId())) { + this.takeOverFromOtherViewers(viewer, ownerUniqueId); + this.sendEnderchestInUseNotice(viewer, enderchest); + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + PageKey key = new PageKey(ownerUniqueId, page); + PageSession session = this.claimSession(key, enderchest, pages); + StorageGui gui = session.gui(); + Inventory inventory = gui.getInventory(); + + if (viewer.getOpenInventory().getTopInventory() == inventory) { + this.releaseSession(key); + return; + } + + this.refreshNavigationItems(session, key, enderchest, pages); + enderchest.attachViewer(viewer.getUniqueId()); + gui.open(viewer); + + if (viewer.getOpenInventory().getTopInventory() != inventory) { + enderchest.detachViewer(); + this.releaseSession(key); + this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); + return; + } + + this.sendPageOpenedNotice(viewer, enderchest, page, pages); + } + + private void takeOverFromOtherViewers(Player viewer, UUID ownerUniqueId) { + if (viewer.getUniqueId().equals(ownerUniqueId)) { + return; + } + + for (Map.Entry openPage : this.openPages.entrySet()) { + if (!openPage.getKey().ownerUniqueId().equals(ownerUniqueId)) { + continue; + } + + for (HumanEntity other : new ArrayList<>(openPage.getValue().gui().getInventory().getViewers())) { + this.scheduler.run(other, other::closeInventory); + } + } + } + + private PageSession claimSession(PageKey key, Enderchest enderchest, int pages) { + PageSession session = this.openPages.computeIfAbsent(key, + pageKey -> new PageSession(this.createGui(pageKey, enderchest, pages), enderchest, pages)); + + session.claims++; + return session; + } + + private void releaseSession(PageKey key) { + PageSession session = this.openPages.get(key); + if (session == null) { + return; + } + + session.claims--; + + if (session.claims <= 0) { + this.openPages.remove(key, session); + } + } + + private StorageGui createGui(PageKey key, Enderchest enderchest, int pages) { + EnderchestLayout layout = enderchest.getLayout(); + int page = key.page(); + + StorageGui gui = Gui.storage() + .title(this.miniMessage.deserialize(this.fillPlaceholders(this.settings.pages().title(), key, enderchest, pages))) + .rows(layout.rows()) + .create(); + + Inventory inventory = gui.getInventory(); + ItemStack[] contents = enderchest.getPageContents(page); + for (int index = 0; index < contents.length; index++) { + inventory.setItem(layout.storageSlot(page, index), contents[index]); + } + + this.setNavigationItems(gui, key, enderchest, pages); + gui.addSlotAction(layout.nextPageSlot(), event -> event.setCancelled(true)); + + if (page > EnderchestLayout.FIRST_PAGE) { + gui.addSlotAction(layout.previousPageSlot(), event -> event.setCancelled(true)); + } + + gui.setDragAction(event -> { + if (event.getRawSlots().stream().anyMatch(slot -> layout.isNavigationSlot(page, slot))) { + event.setCancelled(true); + } + }); + gui.setCloseGuiAction(event -> this.onClose(key, enderchest, event)); + + return gui; + } + + private void refreshNavigationItems(PageSession session, PageKey key, Enderchest enderchest, int pages) { + if (session.renderedPages == pages) { + return; + } + + session.renderedPages = pages; + this.setNavigationItems(session.gui(), key, enderchest, pages); + } + + private void setNavigationItems(StorageGui gui, PageKey key, Enderchest enderchest, int pages) { + EnderchestSettings.NavigationSettings navigation = this.settings.pages().navigation(); + EnderchestLayout layout = enderchest.getLayout(); + int page = key.page(); + + gui.updateItem(layout.nextPageSlot(), this.createNavigationItem(navigation.nextPage(), key, enderchest, pages) + .asGuiItem(event -> this.onNavigationClick(event, key.ownerUniqueId(), nextPage(page), pages))); + + if (page > EnderchestLayout.FIRST_PAGE) { + gui.updateItem(layout.previousPageSlot(), this.createNavigationItem(navigation.previousPage(), key, enderchest, pages) + .asGuiItem(event -> this.onNavigationClick(event, key.ownerUniqueId(), previousPage(page), pages))); + } + } + + private ItemBuilder createNavigationItem(EnderchestSettings.ItemSettings item, PageKey key, Enderchest enderchest, int pages) { + List lore = item.lore().stream() + .map(line -> this.fillPlaceholders(line, key, enderchest, pages)) + .map(line -> AdventureUtil.resetItalic(this.miniMessage.deserialize(line))) + .toList(); + + ItemBuilder builder = ItemBuilder.from(item.material()) + .lore(lore) + .glow(item.glow()); + + if (item.name().isEmpty()) { + return builder; + } + + return builder.name(AdventureUtil.resetItalic(this.miniMessage.deserialize(this.fillPlaceholders(item.name(), key, enderchest, pages)))); + } + + private void onNavigationClick(InventoryClickEvent event, UUID ownerUniqueId, int page, int pages) { + event.setCancelled(true); + + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + if (page > pages) { + player.closeInventory(); + this.sendPageUnavailableNotice(player, page, pages); + return; + } + + UUID playerUniqueId = player.getUniqueId(); + if (this.pageSwitchDelay.hasDelay(playerUniqueId)) { + this.sendPageSwitchDelayNotice(player, this.pageSwitchDelay.getRemaining(playerUniqueId)); + return; + } + + this.pageSwitchDelay.markDelay(playerUniqueId); + this.loadAndOpenPage(player, ownerUniqueId, null, page); + } + + private void onClose(PageKey key, Enderchest enderchest, InventoryCloseEvent event) { + updatePageFromInventory(key, enderchest, event.getInventory()); + enderchest.detachViewer(); + this.releaseSession(key); + + this.enderchestManager.saveEnderchest(enderchest); + this.enderchestManager.unloadIdleEnderchest(key.ownerUniqueId()); + } + + private void sendPageOpenedNotice(Player viewer, Enderchest enderchest, int page, int pages) { + boolean ownEnderchest = viewer.getUniqueId().equals(enderchest.getOwnerUniqueId()); + + this.noticeService.create() + .notice(translation -> ownEnderchest + ? translation.enderchest().openedEnderchestPage() + : translation.enderchest().openedTargetPlayerEnderchest()) + .placeholder(PLAYER_PLACEHOLDER, enderchest.getOwnerName()) + .placeholder(PAGE_PLACEHOLDER, String.valueOf(page)) + .placeholder(PAGES_PLACEHOLDER, String.valueOf(pages)) + .player(viewer.getUniqueId()) + .send(); + } + + private void sendPageUnavailableNotice(Player viewer, int page, int pages) { + this.noticeService.create() + .notice(translation -> translation.enderchest().enderchestPageUnavailable()) + .placeholder(PAGE_PLACEHOLDER, String.valueOf(page)) + .placeholder(PAGES_PLACEHOLDER, String.valueOf(pages)) + .player(viewer.getUniqueId()) + .send(); + } + + private void sendPageSwitchDelayNotice(Player viewer, Duration remaining) { + this.noticeService.create() + .notice(translation -> translation.enderchest().enderchestPageSwitchDelay()) + .placeholder(TIME_PLACEHOLDER, DurationUtil.format(remaining, true)) + .player(viewer.getUniqueId()) + .send(); + } + + private void sendEnderchestInUseNotice(Player viewer, Enderchest enderchest) { + this.noticeService.create() + .notice(translation -> translation.enderchest().enderchestInUse()) + .placeholder(PLAYER_PLACEHOLDER, enderchest.getOwnerName()) + .player(viewer.getUniqueId()) + .send(); + } + + private void sendEnderchestEmptyNotice(Player viewer, Enderchest enderchest) { + this.noticeService.create() + .notice(translation -> translation.enderchest().playerEnderchestEmpty()) + .placeholder(PLAYER_PLACEHOLDER, enderchest.getOwnerName()) + .player(viewer.getUniqueId()) + .send(); + } + + private String fillPlaceholders(String text, PageKey key, Enderchest enderchest, int pages) { + return text + .replace(PLAYER_PLACEHOLDER, enderchest.getOwnerName()) + .replace(PAGE_PLACEHOLDER, String.valueOf(key.page())) + .replace(PAGES_PLACEHOLDER, String.valueOf(pages)) + .replace(NEXT_PLACEHOLDER, String.valueOf(nextPage(key.page()))) + .replace(PREVIOUS_PLACEHOLDER, String.valueOf(previousPage(key.page()))); + } + + private static void updatePageFromInventory(PageKey key, Enderchest enderchest, Inventory inventory) { + EnderchestLayout layout = enderchest.getLayout(); + int page = key.page(); + + ItemStack[] contents = new ItemStack[layout.capacityOf(page)]; + for (int index = 0; index < contents.length; index++) { + contents[index] = inventory.getItem(layout.storageSlot(page, index)); + } + + enderchest.updatePage(page, contents); + } + + private static int nextPage(int page) { + return page + 1; + } + + private static int previousPage(int page) { + return Math.max(EnderchestLayout.FIRST_PAGE, page - 1); + } + + private record PageKey(UUID ownerUniqueId, int page) {} + + private static final class PageSession { + + private final StorageGui gui; + private final Enderchest enderchest; + + private int claims; + private int renderedPages; + + private PageSession(StorageGui gui, Enderchest enderchest, int renderedPages) { + this.gui = gui; + this.enderchest = enderchest; + this.renderedPages = renderedPages; + } + + private StorageGui gui() { + return this.gui; + } + + private Enderchest enderchest() { + return this.enderchest; + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestLayout.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestLayout.java new file mode 100644 index 000000000..aae037d2f --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestLayout.java @@ -0,0 +1,86 @@ +package com.eternalcode.core.feature.enderchest; + +record EnderchestLayout(int rows) { + + static final int FIRST_PAGE = 1; + static final int MIN_ROWS = 1; + static final int MAX_ROWS = 6; + + private static final int SLOTS_PER_ROW = 9; + + EnderchestLayout { + if (rows < MIN_ROWS || rows > MAX_ROWS) { + throw new IllegalArgumentException("Rows must be between " + MIN_ROWS + " and " + MAX_ROWS + ", got " + rows); + } + } + + static EnderchestLayout ofRows(int rows) { + return new EnderchestLayout(Math.clamp(rows, MIN_ROWS, MAX_ROWS)); + } + + int inventorySize() { + return this.rows * SLOTS_PER_ROW; + } + + int nextPageSlot() { + return this.inventorySize() - 1; + } + + int previousPageSlot() { + return this.inventorySize() - SLOTS_PER_ROW; + } + + int capacityOf(int page) { + return this.isFirstPage(page) ? this.inventorySize() - 1 : this.inventorySize() - 2; + } + + int offsetOf(int page) { + if (page < FIRST_PAGE) { + throw new IllegalArgumentException("Page must be positive, got " + page); + } + + return this.totalSlots(page - 1); + } + + int totalSlots(int pages) { + if (pages <= 0) { + return 0; + } + + return this.capacityOf(FIRST_PAGE) + (pages - 1) * this.capacityOf(FIRST_PAGE + 1); + } + + int pageOf(int slot) { + int firstPageCapacity = this.capacityOf(FIRST_PAGE); + + if (slot < firstPageCapacity) { + return FIRST_PAGE; + } + + return (slot - firstPageCapacity) / this.capacityOf(FIRST_PAGE + 1) + FIRST_PAGE + 1; + } + + int pagesIn(int slots) { + return slots <= 0 ? 0 : this.pageOf(slots - 1); + } + + boolean isNavigationSlot(int page, int slot) { + if (slot == this.nextPageSlot()) { + return true; + } + + return !this.isFirstPage(page) && slot == this.previousPageSlot(); + } + + int storageSlot(int page, int index) { + if (this.isFirstPage(page) || index < this.previousPageSlot()) { + return index; + } + + return index + 1; + } + + private boolean isFirstPage(int page) { + return page == FIRST_PAGE; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java new file mode 100644 index 000000000..b9013a7df --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java @@ -0,0 +1,219 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.core.feature.enderchest.database.EnderchestRepository; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +@Service +class EnderchestManager { + + private static final Duration SHUTDOWN_WRITE_TIMEOUT = Duration.ofSeconds(10); + private static final int MIN_PAGES = 1; + private static final Predicate UNVIEWED = Predicate.not(Enderchest::hasViewers); + + private final Map> enderchests = new ConcurrentHashMap<>(); + private final Set reportedFailures = ConcurrentHashMap.newKeySet(); + private final EnderchestRepository repository; + private final EnderchestSettings settings; + private final MinecraftScheduler scheduler; + private final Server server; + private final Logger logger; + + @Inject + EnderchestManager( + EnderchestRepository repository, + EnderchestSettings settings, + MinecraftScheduler scheduler, + Server server, + Logger logger + ) { + this.repository = repository; + this.settings = settings; + this.scheduler = scheduler; + this.server = server; + this.logger = logger; + } + + CompletableFuture loadEnderchest(UUID ownerUniqueId, @Nullable String ownerName) { + CompletableFuture future = this.enderchests.computeIfAbsent(ownerUniqueId, uniqueId -> { + EnderchestLayout layout = EnderchestLayout.ofRows(this.settings.pages().rows()); + String name = ownerName != null ? ownerName : this.cachedOwnerName(uniqueId); + + return this.repository.findPages(uniqueId).thenApply(pages -> Enderchest.fromPages(uniqueId, name, layout, pages)); + }); + + Enderchest cachedEnderchest = loadedEnderchest(future); + if (cachedEnderchest != null && ownerName != null) { + cachedEnderchest.setOwnerName(ownerName); + } + + future.whenComplete((enderchest, throwable) -> { + if (throwable != null) { + this.scheduler.run(() -> this.enderchests.remove(ownerUniqueId, future)); + } + }); + + return future; + } + + boolean isLoaded(UUID ownerUniqueId, Enderchest enderchest) { + return loadedEnderchest(this.enderchests.get(ownerUniqueId)) == enderchest; + } + + void saveEnderchest(Enderchest enderchest) { + EnderchestWrite write = enderchest.prepareWrite(); + if (write.isEmpty()) { + return; + } + + this.repository.savePages(enderchest.getOwnerUniqueId(), write) + .whenComplete((unused, throwable) -> this.scheduler.run(() -> { + this.applyWriteResult(enderchest, write, throwable); + enderchest.finishWrite(); + + if (enderchest.hasNothingToWrite()) { + this.unloadIdleEnderchest(enderchest.getOwnerUniqueId()); + } + })); + } + + private void applyWriteResult(Enderchest enderchest, EnderchestWrite write, Throwable throwable) { + if (throwable == null) { + this.reportedFailures.remove(enderchest.getOwnerUniqueId()); + return; + } + + enderchest.restoreDirtyPages(write); + this.reportFailure(enderchest, throwable); + } + + int getPageLimit(Player player) { + int limit = this.settings.pages().limits().entrySet().stream() + .filter(entry -> player.hasPermission(entry.getKey())) + .mapToInt(Map.Entry::getValue) + .max() + .orElse(this.settings.pages().defaultLimit()); + + return Math.max(MIN_PAGES, limit); + } + + int getAccessiblePages(Enderchest enderchest) { + Player owner = this.server.getPlayer(enderchest.getOwnerUniqueId()); + int limit = owner == null ? this.getHighestConfiguredLimit() : this.getPageLimit(owner); + + return Math.max(limit, enderchest.getUsedPages()); + } + + void unloadIdleEnderchest(UUID ownerUniqueId) { + this.unloadEnderchestIf(ownerUniqueId, UNVIEWED.and(enderchest -> this.server.getPlayer(ownerUniqueId) == null)); + } + + void unloadUnviewedEnderchest(UUID ownerUniqueId) { + this.unloadEnderchestIf(ownerUniqueId, UNVIEWED); + } + + void unloadEnderchestOnQuit(UUID ownerUniqueId) { + CompletableFuture loadingEnderchest = this.enderchests.get(ownerUniqueId); + + if (loadingEnderchest != null && !loadingEnderchest.isDone()) { + loadingEnderchest.whenComplete((enderchest, throwable) -> this.scheduler.run(() -> this.unloadIdleEnderchest(ownerUniqueId))); + return; + } + + this.unloadUnviewedEnderchest(ownerUniqueId); + } + + void unloadAllEnderchests() { + for (UUID ownerUniqueId : List.copyOf(this.enderchests.keySet())) { + this.unloadUnviewedEnderchest(ownerUniqueId); + } + } + + void shutdown() { + for (CompletableFuture future : this.enderchests.values()) { + Enderchest enderchest = loadedEnderchest(future); + if (enderchest == null) { + continue; + } + + EnderchestWrite write = enderchest.prepareWrite(); + if (write.isEmpty()) { + continue; + } + + this.repository.savePages(enderchest.getOwnerUniqueId(), write) + .whenComplete((unused, throwable) -> { + enderchest.finishWrite(); + + if (throwable != null) { + this.logger.log(Level.SEVERE, "Failed to save ender chest of " + enderchest.getOwnerName() + + " during shutdown, its changes are lost", throwable); + } + }); + } + + this.repository.shutdownWrites(SHUTDOWN_WRITE_TIMEOUT); + } + + private void unloadEnderchestIf(UUID ownerUniqueId, Predicate idle) { + CompletableFuture future = this.enderchests.get(ownerUniqueId); + Enderchest enderchest = loadedEnderchest(future); + + if (enderchest == null || !idle.test(enderchest)) { + return; + } + + this.saveEnderchest(enderchest); + + if (enderchest.isPersisted()) { + this.enderchests.remove(ownerUniqueId, future); + this.reportedFailures.remove(ownerUniqueId); + } + } + + private void reportFailure(Enderchest enderchest, Throwable throwable) { + if (!this.reportedFailures.add(enderchest.getOwnerUniqueId())) { + return; + } + + this.logger.log(Level.SEVERE, "Failed to save ender chest of " + enderchest.getOwnerName() + + ", it will be retried on every close and reported again after the next successful write", throwable); + } + + private int getHighestConfiguredLimit() { + int configuredLimit = this.settings.pages().limits().values().stream() + .mapToInt(Integer::intValue) + .max() + .orElse(MIN_PAGES); + + return Math.max(MIN_PAGES, Math.max(configuredLimit, this.settings.pages().defaultLimit())); + } + + private String cachedOwnerName(UUID ownerUniqueId) { + String name = this.server.getOfflinePlayer(ownerUniqueId).getName(); + return name != null ? name : ownerUniqueId.toString(); + } + + @Nullable + private static Enderchest loadedEnderchest(@Nullable CompletableFuture future) { + if (future == null || !future.isDone() || future.isCompletedExceptionally()) { + return null; + } + + return future.join(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestServiceImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestServiceImpl.java new file mode 100644 index 000000000..2d85970ca --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestServiceImpl.java @@ -0,0 +1,59 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +@Service +class EnderchestServiceImpl implements EnderchestService { + + private final EnderchestInventory enderchestInventory; + private final EnderchestManager enderchestManager; + private final EnderchestSettings settings; + + @Inject + EnderchestServiceImpl(EnderchestInventory enderchestInventory, EnderchestManager enderchestManager, EnderchestSettings settings) { + this.enderchestInventory = enderchestInventory; + this.enderchestManager = enderchestManager; + this.settings = settings; + } + + @Override + public boolean areEnderchestsBlocked() { + return this.settings.enderchestsBlocked(); + } + + @Override + public boolean isVanillaEnderchestReplaced() { + return !this.areEnderchestsBlocked() && this.settings.replaceVanillaEnderchest(); + } + + @Override + public void openEnderchest(Player player) { + if (this.areEnderchestsBlocked()) { + return; + } + + if (!this.isVanillaEnderchestReplaced()) { + player.openInventory(player.getEnderChest()); + return; + } + + this.enderchestInventory.openPage(player, player, EnderchestLayout.FIRST_PAGE); + } + + @Override + public void openEnderchest(Player viewer, OfflinePlayer owner, int page) { + if (!this.isVanillaEnderchestReplaced()) { + return; + } + + this.enderchestInventory.openPage(viewer, owner, page); + } + + @Override + public int getPageLimit(Player player) { + return this.enderchestManager.getPageLimit(player); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSessionController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSessionController.java new file mode 100644 index 000000000..f5293a06d --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSessionController.java @@ -0,0 +1,78 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Controller; +import com.eternalcode.core.publish.Subscribe; +import com.eternalcode.core.publish.event.EternalInitializeEvent; +import com.eternalcode.core.publish.event.EternalReloadEvent; +import com.eternalcode.core.publish.event.EternalShutdownEvent; +import org.bukkit.Server; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.WorldSaveEvent; + +@Controller +class EnderchestSessionController implements Listener { + + private final EnderchestManager enderchestManager; + private final EnderchestInventory enderchestInventory; + private final VanillaEnderchestMigration migration; + private final MinecraftScheduler scheduler; + private final Server server; + + @Inject + EnderchestSessionController( + EnderchestManager enderchestManager, + EnderchestInventory enderchestInventory, + VanillaEnderchestMigration migration, + MinecraftScheduler scheduler, + Server server + ) { + this.enderchestManager = enderchestManager; + this.enderchestInventory = enderchestInventory; + this.migration = migration; + this.scheduler = scheduler; + this.server = server; + } + + @EventHandler(priority = EventPriority.MONITOR) + void onJoin(PlayerJoinEvent event) { + this.migration.migratePlayer(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + void onQuit(PlayerQuitEvent event) { + this.enderchestManager.unloadEnderchestOnQuit(event.getPlayer().getUniqueId()); + } + + @EventHandler + void onWorldSave(WorldSaveEvent event) { + if (event.getWorld().equals(this.server.getWorlds().getFirst())) { + this.enderchestInventory.saveOpenPages(); + } + } + + @Subscribe(EternalInitializeEvent.class) + void onInitialize() { + this.scheduler.run(this.migration::migrateOnlinePlayers); + } + + @Subscribe(EternalReloadEvent.class) + void onReload() { + this.scheduler.run(() -> { + this.enderchestInventory.closeAllPages(); + this.enderchestManager.unloadAllEnderchests(); + this.migration.migrateOnlinePlayers(); + }); + } + + @Subscribe(EternalShutdownEvent.class) + void onShutdown() { + this.enderchestInventory.closeAllPages(); + this.enderchestManager.shutdown(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSettings.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSettings.java new file mode 100644 index 000000000..23b060ee2 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestSettings.java @@ -0,0 +1,50 @@ +package com.eternalcode.core.feature.enderchest; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.bukkit.Material; + +public interface EnderchestSettings { + + boolean replaceVanillaEnderchest(); + + boolean enderchestsBlocked(); + + boolean sharedViewingBlocked(); + + PageSettings pages(); + + interface PageSettings { + + int rows(); + + String title(); + + Duration switchDelay(); + + Map limits(); + + int defaultLimit(); + + NavigationSettings navigation(); + } + + interface NavigationSettings { + + ItemSettings nextPage(); + + ItemSettings previousPage(); + } + + interface ItemSettings { + + Material material(); + + String name(); + + List lore(); + + boolean glow(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestWrite.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestWrite.java new file mode 100644 index 000000000..de2a2dfe1 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestWrite.java @@ -0,0 +1,12 @@ +package com.eternalcode.core.feature.enderchest; + +import java.util.List; + +public record EnderchestWrite(List pages, boolean replaceAll) { + + public static final EnderchestWrite NONE = new EnderchestWrite(List.of(), false); + + public boolean isEmpty() { + return this.pages.isEmpty() && !this.replaceAll; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/PageContents.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/PageContents.java new file mode 100644 index 000000000..96a6311aa --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/PageContents.java @@ -0,0 +1,16 @@ +package com.eternalcode.core.feature.enderchest; + +import org.bukkit.inventory.ItemStack; + +public record PageContents(int page, ItemStack[] items) { + + public boolean isEmpty() { + for (ItemStack item : this.items) { + if (item != null) { + return false; + } + } + + return true; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestController.java new file mode 100644 index 000000000..a23f8da93 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestController.java @@ -0,0 +1,134 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Controller; +import com.eternalcode.core.notice.NoticeService; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.block.BlockState; +import org.bukkit.block.EnderChest; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.Nullable; + +@Controller +class VanillaEnderchestController implements Listener { + + private final Map openLids = new ConcurrentHashMap<>(); + + private final EnderchestInventory enderchestInventory; + private final EnderchestSettings settings; + private final NoticeService noticeService; + private final MinecraftScheduler scheduler; + + @Inject + VanillaEnderchestController( + EnderchestInventory enderchestInventory, + EnderchestSettings settings, + NoticeService noticeService, + MinecraftScheduler scheduler + ) { + this.enderchestInventory = enderchestInventory; + this.settings = settings; + this.noticeService = noticeService; + this.scheduler = scheduler; + } + + @EventHandler(ignoreCancelled = true) + void onOpen(InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player player)) { + return; + } + + Inventory inventory = event.getInventory(); + + if (this.enderchestInventory.isOpenPage(inventory)) { + this.setLid(this.openLids.get(player.getUniqueId()), true); + return; + } + + if (inventory.getType() != InventoryType.ENDER_CHEST) { + return; + } + + if (this.settings.enderchestsBlocked()) { + event.setCancelled(true); + this.noticeService.create() + .notice(translation -> translation.enderchest().enderchestsBlocked()) + .player(player.getUniqueId()) + .send(); + return; + } + + if (!this.settings.replaceVanillaEnderchest()) { + return; + } + + InventoryHolder holder = inventory.getHolder(); + OfflinePlayer chestOwner = holder instanceof Player holderPlayer ? holderPlayer : player; + + event.setCancelled(true); + this.rememberLid(player.getUniqueId(), inventory.getLocation()); + this.enderchestInventory.openPage(player, chestOwner, EnderchestLayout.FIRST_PAGE); + } + + @EventHandler(priority = EventPriority.MONITOR) + void onClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player player) || !this.enderchestInventory.isOpenPage(event.getInventory())) { + return; + } + + this.scheduler.run(player, () -> { + if (this.enderchestInventory.isOpenPage(player.getOpenInventory().getTopInventory())) { + return; + } + + this.setLid(this.openLids.remove(player.getUniqueId()), false); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + void onQuit(PlayerQuitEvent event) { + this.setLid(this.openLids.remove(event.getPlayer().getUniqueId()), false); + } + + private void rememberLid(UUID playerUniqueId, @Nullable Location location) { + if (location == null) { + this.openLids.remove(playerUniqueId); + return; + } + + this.openLids.put(playerUniqueId, location); + this.setLid(location, true); + } + + private void setLid(@Nullable Location location, boolean open) { + if (location == null || location.getWorld() == null || !location.isChunkLoaded()) { + return; + } + + BlockState state = location.getBlock().getState(); + if (!(state instanceof EnderChest enderChest)) { + return; + } + + if (open) { + enderChest.open(); + return; + } + + enderChest.close(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java new file mode 100644 index 000000000..9169e8066 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java @@ -0,0 +1,130 @@ +package com.eternalcode.core.feature.enderchest; + +import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.core.feature.enderchest.database.EnderchestRepository; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +@Service +class VanillaEnderchestMigration { + + private final Set runningImports = ConcurrentHashMap.newKeySet(); + + private final EnderchestManager enderchestManager; + private final EnderchestRepository repository; + private final EnderchestSettings settings; + private final MinecraftScheduler scheduler; + private final Server server; + private final Logger logger; + + @Inject + VanillaEnderchestMigration( + EnderchestManager enderchestManager, + EnderchestRepository repository, + EnderchestSettings settings, + MinecraftScheduler scheduler, + Server server, + Logger logger + ) { + this.enderchestManager = enderchestManager; + this.repository = repository; + this.settings = settings; + this.scheduler = scheduler; + this.server = server; + this.logger = logger; + } + + void migrateOnlinePlayers() { + for (Player player : this.server.getOnlinePlayers()) { + this.migratePlayer(player); + } + } + + void migratePlayer(Player player) { + if (this.settings.enderchestsBlocked() || !this.settings.replaceVanillaEnderchest()) { + return; + } + + this.enderchestManager.loadEnderchest(player.getUniqueId(), player.getName()) + .thenAccept(enderchest -> this.scheduler.run(player, () -> this.importVanillaItems(player, enderchest))) + .exceptionally(FutureHandler::handleException); + } + + private void importVanillaItems(Player player, Enderchest enderchest) { + UUID ownerUniqueId = enderchest.getOwnerUniqueId(); + + if (!player.isOnline() || !this.enderchestManager.isLoaded(ownerUniqueId, enderchest)) { + return; + } + + if (enderchest.hasViewers() || enderchest.isWriting() || this.runningImports.contains(ownerUniqueId)) { + return; + } + + Inventory vanillaEnderchest = player.getEnderChest(); + ItemStack[] vanillaItems = vanillaEnderchest.getContents(); + + if (enderchest.hasAlreadyImported(vanillaItems)) { + if (enderchest.hasNothingToWrite()) { + vanillaEnderchest.clear(); + } + + this.enderchestManager.unloadUnviewedEnderchest(ownerUniqueId); + return; + } + + int[] filledSlots = enderchest.insertItems(vanillaItems); + if (filledSlots.length == 0) { + this.enderchestManager.unloadUnviewedEnderchest(ownerUniqueId); + return; + } + + EnderchestWrite write = enderchest.prepareWrite(); + if (write.isEmpty()) { + this.enderchestManager.unloadUnviewedEnderchest(ownerUniqueId); + return; + } + + this.runningImports.add(ownerUniqueId); + + this.repository.savePages(ownerUniqueId, write) + .whenComplete((unused, throwable) -> this.scheduler.run(() -> { + this.applyImportResult(player, enderchest, write, filledSlots, throwable); + this.runningImports.remove(ownerUniqueId); + enderchest.finishWrite(); + this.enderchestManager.unloadUnviewedEnderchest(ownerUniqueId); + })); + } + + private void applyImportResult( + Player player, + Enderchest enderchest, + EnderchestWrite write, + int[] filledSlots, + Throwable throwable + ) { + if (throwable != null) { + enderchest.restoreDirtyPages(write); + enderchest.clearSlots(filledSlots); + this.logger.log(Level.SEVERE, "Failed to migrate the vanilla ender chest of " + enderchest.getOwnerName() + + ", its items stay in the vanilla one and the migration is retried on the next join or reload", throwable); + return; + } + + this.scheduler.run(player, () -> { + if (player.isOnline()) { + player.getEnderChest().clear(); + } + }); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java new file mode 100644 index 000000000..5d8453dfd --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java @@ -0,0 +1,49 @@ +package com.eternalcode.core.feature.enderchest.database; + +import com.eternalcode.core.feature.enderchest.PageContents; +import com.j256.ormlite.field.DataType; +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.util.UUID; + +@DatabaseTable(tableName = EnderchestPageTable.TABLE_NAME) +class EnderchestPageTable { + + static final String TABLE_NAME = "eternal_core_enderchests"; + static final String OWNER_COLUMN = "owner"; + static final String PAGE_COLUMN = "page"; + static final String CONTENTS_COLUMN = "contents"; + + @DatabaseField(columnName = "id", id = true) + private String id; + + @DatabaseField(columnName = OWNER_COLUMN, index = true) + private UUID ownerUniqueId; + + @DatabaseField(columnName = PAGE_COLUMN) + private int page; + + @DatabaseField(columnName = CONTENTS_COLUMN, dataType = DataType.BYTE_ARRAY) + private byte[] contents; + + EnderchestPageTable() {} + + private EnderchestPageTable(UUID ownerUniqueId, int page, byte[] contents) { + this.id = idOf(ownerUniqueId, page); + this.ownerUniqueId = ownerUniqueId; + this.page = page; + this.contents = contents; + } + + static String idOf(UUID ownerUniqueId, int page) { + return ownerUniqueId + ":" + page; + } + + static EnderchestPageTable from(UUID ownerUniqueId, PageContents contents) { + return new EnderchestPageTable(ownerUniqueId, contents.page(), ItemCodec.encode(contents.items())); + } + + PageContents toContents() { + return new PageContents(this.page, ItemCodec.decode(this.contents)); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java new file mode 100644 index 000000000..388a5fec2 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java @@ -0,0 +1,17 @@ +package com.eternalcode.core.feature.enderchest.database; + +import com.eternalcode.core.feature.enderchest.EnderchestWrite; +import com.eternalcode.core.feature.enderchest.PageContents; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface EnderchestRepository { + + CompletableFuture> findPages(UUID ownerUniqueId); + + CompletableFuture savePages(UUID ownerUniqueId, EnderchestWrite write); + + void shutdownWrites(Duration timeout); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java new file mode 100644 index 000000000..2146666df --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java @@ -0,0 +1,156 @@ +package com.eternalcode.core.feature.enderchest.database; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.core.database.AbstractRepositoryOrmLite; +import com.eternalcode.core.database.DatabaseException; +import com.eternalcode.core.database.DatabaseManager; +import com.eternalcode.core.feature.enderchest.EnderchestWrite; +import com.eternalcode.core.feature.enderchest.PageContents; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Repository; +import com.j256.ormlite.dao.Dao; +import com.j256.ormlite.dao.GenericRawResults; +import com.j256.ormlite.jdbc.db.MysqlDatabaseType; +import com.j256.ormlite.misc.TransactionManager; +import com.j256.ormlite.stmt.DeleteBuilder; +import com.j256.ormlite.support.ConnectionSource; +import com.j256.ormlite.table.TableUtils; +import java.sql.SQLException; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +@Repository +class EnderchestRepositoryOrmLite extends AbstractRepositoryOrmLite implements EnderchestRepository { + + private static final String WIDEN_CONTENTS_COLUMN = "ALTER TABLE " + EnderchestPageTable.TABLE_NAME + + " MODIFY " + EnderchestPageTable.CONTENTS_COLUMN + " MEDIUMBLOB"; + + private static final String CONTENTS_COLUMN_TYPE_QUERY = "SELECT DATA_TYPE FROM information_schema.COLUMNS" + + " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"; + + private static final Set WIDE_BLOB_TYPES = Set.of("mediumblob", "longblob"); + + private final ExecutorService writer = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "EternalCore Enderchest Writer"); + thread.setDaemon(true); + return thread; + }); + + private final Logger logger; + + @Inject + private EnderchestRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler, Logger logger) throws SQLException { + super(databaseManager, scheduler); + this.logger = logger; + + ConnectionSource connectionSource = databaseManager.connectionSource(); + TableUtils.createTableIfNotExists(connectionSource, EnderchestPageTable.class); + + if (connectionSource.getDatabaseType() instanceof MysqlDatabaseType) { + this.widenContentsColumn(databaseManager.getDao(EnderchestPageTable.class)); + } + } + + @Override + public CompletableFuture> findPages(UUID ownerUniqueId) { + return this.action(EnderchestPageTable.class, dao -> dao.queryBuilder() + .orderBy(EnderchestPageTable.PAGE_COLUMN, true) + .where() + .eq(EnderchestPageTable.OWNER_COLUMN, ownerUniqueId) + .query() + .stream() + .map(EnderchestPageTable::toContents) + .toList()); + } + + @Override + public CompletableFuture savePages(UUID ownerUniqueId, EnderchestWrite write) { + try { + return CompletableFuture.runAsync(() -> this.writePages(ownerUniqueId, write), this.writer); + } + catch (RejectedExecutionException exception) { + return CompletableFuture.failedFuture( + new DatabaseException("Ender chest writes are already closed, cannot save pages of " + ownerUniqueId, exception)); + } + } + + private void writePages(UUID ownerUniqueId, EnderchestWrite write) { + try { + this.writeInTransaction(this.databaseManager.getDao(EnderchestPageTable.class), ownerUniqueId, write); + } + catch (Exception exception) { + throw new DatabaseException("Failed to save ender chest pages of " + ownerUniqueId, exception); + } + } + + @Override + public void shutdownWrites(Duration timeout) { + this.writer.shutdown(); + + try { + if (!this.writer.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + this.logger.severe("Ender chest writes did not reach the database within " + timeout.toSeconds() + "s, the remaining ones are lost"); + } + } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private void writeInTransaction(Dao dao, UUID ownerUniqueId, EnderchestWrite write) throws SQLException { + boolean replaceAll = write.replaceAll(); + + TransactionManager.callInTransaction(dao.getConnectionSource(), () -> { + if (replaceAll) { + DeleteBuilder deleteBuilder = dao.deleteBuilder(); + deleteBuilder.where().eq(EnderchestPageTable.OWNER_COLUMN, ownerUniqueId); + deleteBuilder.delete(); + } + + for (PageContents page : write.pages()) { + if (!page.isEmpty()) { + dao.createOrUpdate(EnderchestPageTable.from(ownerUniqueId, page)); + continue; + } + + if (!replaceAll) { + dao.deleteById(EnderchestPageTable.idOf(ownerUniqueId, page.page())); + } + } + + return null; + }); + } + + private void widenContentsColumn(Dao dao) throws SQLException { + if (this.hasWideContentsColumn(dao)) { + return; + } + + dao.executeRawNoArgs(WIDEN_CONTENTS_COLUMN); + } + + private boolean hasWideContentsColumn(Dao dao) throws SQLException { + try (GenericRawResults results = dao.queryRaw( + CONTENTS_COLUMN_TYPE_QUERY, + EnderchestPageTable.TABLE_NAME, + EnderchestPageTable.CONTENTS_COLUMN + )) { + String[] row = results.getFirstResult(); + + return row != null && row.length > 0 && WIDE_BLOB_TYPES.contains(row[0].toLowerCase(Locale.ROOT)); + } + catch (Exception exception) { + throw new SQLException("Failed to read the type of the ender chest contents column", exception); + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java new file mode 100644 index 000000000..1c131389c --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java @@ -0,0 +1,83 @@ +package com.eternalcode.core.feature.enderchest.database; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import org.bukkit.inventory.ItemStack; + +// Bukkit only got serializeItemsAsBytes in 1.21.1, so we write every item on its own +// with serializeAsBytes to keep the same rows readable all the way down to 1.19.3. +final class ItemCodec { + + private static final byte FORMAT_VERSION = 2; + private static final int EMPTY_SLOT = -1; + private static final int MAX_SLOTS = 1024; + + private ItemCodec() {} + + static byte[] encode(ItemStack[] items) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + try (DataOutputStream output = new DataOutputStream(buffer)) { + output.writeByte(FORMAT_VERSION); + output.writeInt(items.length); + + for (ItemStack item : items) { + if (item == null) { + output.writeInt(EMPTY_SLOT); + continue; + } + + byte[] serialized = item.serializeAsBytes(); + output.writeInt(serialized.length); + output.write(serialized); + } + } + catch (IOException exception) { + throw new IllegalStateException("Failed to encode ender chest items", exception); + } + + return buffer.toByteArray(); + } + + static ItemStack[] decode(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return new ItemStack[0]; + } + + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { + byte version = input.readByte(); + if (version != FORMAT_VERSION) { + throw new IllegalStateException("Unknown ender chest item format: " + version); + } + + int slots = input.readInt(); + if (slots < 0 || slots > MAX_SLOTS) { + throw new IllegalStateException("Ender chest page declares " + slots + " slots"); + } + + ItemStack[] items = new ItemStack[slots]; + for (int slot = 0; slot < slots; slot++) { + int length = input.readInt(); + if (length == EMPTY_SLOT) { + continue; + } + + if (length < 0 || length > bytes.length) { + throw new IllegalStateException("Ender chest slot " + slot + " declares " + length + " bytes"); + } + + byte[] serialized = new byte[length]; + input.readFully(serialized); + items[slot] = ItemStack.deserializeBytes(serialized); + } + + return items; + } + catch (IOException exception) { + throw new IllegalStateException("Failed to decode ender chest items", exception); + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/ENEnderchestMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/ENEnderchestMessages.java new file mode 100644 index 000000000..b8f2510e8 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/ENEnderchestMessages.java @@ -0,0 +1,37 @@ +package com.eternalcode.core.feature.enderchest.messages; + +import com.eternalcode.multification.bukkit.notice.BukkitNotice; +import com.eternalcode.multification.notice.Notice; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.bukkit.Sound; + +@Getter +@Accessors(fluent = true) +public class ENEnderchestMessages extends OkaeriConfig implements EnderchestMessages { + + Notice openedEnderchest = Notice.chat("► Ender chest opened!"); + Notice customEnderchestDisabled = Notice.chat("✘ Custom ender chests are disabled on this server!"); + Notice enderchestsBlocked = Notice.chat("✘ Ender chests are disabled on this server!"); + + @Comment({ " ", "# {PAGE} - Page number, {PAGES} - Number of available pages" }) + Notice openedEnderchestPage = BukkitNotice.builder() + .actionBar("► Ender chest page {PAGE}/{PAGES}") + .sound(Sound.BLOCK_ENDER_CHEST_OPEN, 0.5f, 1f) + .build(); + + Notice enderchestPageUnavailable = Notice.chat("✘ Page {PAGE} is not available! You can use {PAGES} page(s)."); + + @Comment({ " ", "# {TIME} - Time left before the next page switch" }) + Notice enderchestPageSwitchDelay = Notice.chat("✘ You can switch the page in: {TIME}"); + + @Comment({ " ", "# {PLAYER} - Ender chest owner, {PAGE} - Page number, {PAGES} - Number of available pages" }) + Notice openedTargetPlayerEnderchest = Notice.chat("► Opened page {PAGE} of {PLAYER}'s ender chest!"); + + @Comment({ " ", "# {PLAYER} - Ender chest owner" }) + Notice playerEnderchestEmpty = Notice.chat("✘ Player {PLAYER} has nothing stored! " + + "If they have not joined since the custom ender chests were enabled, their items are still in the vanilla one."); + Notice enderchestInUse = Notice.chat("✘ The ender chest of {PLAYER} is open somewhere else, try again in a moment!"); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/EnderchestMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/EnderchestMessages.java new file mode 100644 index 000000000..972c46f3c --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/EnderchestMessages.java @@ -0,0 +1,24 @@ +package com.eternalcode.core.feature.enderchest.messages; + +import com.eternalcode.multification.notice.Notice; + +public interface EnderchestMessages { + + Notice openedEnderchest(); + + Notice customEnderchestDisabled(); + + Notice enderchestsBlocked(); + + Notice openedEnderchestPage(); + + Notice enderchestPageUnavailable(); + + Notice enderchestPageSwitchDelay(); + + Notice openedTargetPlayerEnderchest(); + + Notice playerEnderchestEmpty(); + + Notice enderchestInUse(); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/PLEnderchestMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/PLEnderchestMessages.java new file mode 100644 index 000000000..e11e3d86d --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/messages/PLEnderchestMessages.java @@ -0,0 +1,37 @@ +package com.eternalcode.core.feature.enderchest.messages; + +import com.eternalcode.multification.bukkit.notice.BukkitNotice; +import com.eternalcode.multification.notice.Notice; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.bukkit.Sound; + +@Getter +@Accessors(fluent = true) +public class PLEnderchestMessages extends OkaeriConfig implements EnderchestMessages { + + Notice openedEnderchest = Notice.chat("► Otworzono enderchest!"); + Notice customEnderchestDisabled = Notice.chat("✘ Custom enderchesty są wyłączone na tym serwerze!"); + Notice enderchestsBlocked = Notice.chat("✘ Enderchesty są wyłączone na tym serwerze!"); + + @Comment({ " ", "# {PAGE} - Numer strony, {PAGES} - Liczba dostępnych stron" }) + Notice openedEnderchestPage = BukkitNotice.builder() + .actionBar("► Strona enderchestu {PAGE}/{PAGES}") + .sound(Sound.BLOCK_ENDER_CHEST_OPEN, 0.5f, 1f) + .build(); + + Notice enderchestPageUnavailable = Notice.chat("✘ Strona {PAGE} jest niedostępna! Możesz korzystać z {PAGES} stron(y)."); + + @Comment({ " ", "# {TIME} - Czas do następnej zmiany strony" }) + Notice enderchestPageSwitchDelay = Notice.chat("✘ Możesz zmienić stronę dopiero za {TIME}!"); + + @Comment({ " ", "# {PLAYER} - Właściciel enderchestu, {PAGE} - Numer strony, {PAGES} - Liczba dostępnych stron" }) + Notice openedTargetPlayerEnderchest = Notice.chat("► Otworzono stronę {PAGE} enderchestu gracza {PLAYER}!"); + + @Comment({ " ", "# {PLAYER} - Właściciel enderchestu" }) + Notice playerEnderchestEmpty = Notice.chat("✘ Gracz {PLAYER} nie ma nic zapisanego! " + + "Jeśli nie logował się od włączenia custom enderchestów, jego przedmioty są nadal w zwykłym enderchescie."); + Notice enderchestInUse = Notice.chat("✘ Enderchest gracza {PLAYER} jest otwarty gdzie indziej, spróbuj za chwilę!"); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java index d81be3f53..ca1cf57cc 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java @@ -8,6 +8,7 @@ import com.eternalcode.core.feature.playtime.messages.PlaytimeMessages; import com.eternalcode.core.feature.clear.messages.ClearMessages; import com.eternalcode.core.feature.container.messages.ContainerMessages; +import com.eternalcode.core.feature.enderchest.messages.EnderchestMessages; import com.eternalcode.core.feature.repair.messages.RepairMessages; import com.eternalcode.core.litecommand.argument.messages.ArgumentMessages; import com.eternalcode.core.feature.adminchat.messages.AdminChatMessages; @@ -155,6 +156,8 @@ interface Format { ContainerMessages container(); + EnderchestMessages enderchest(); + SetSlotMessages setSlot(); ItemEditMessages itemEdit(); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java index 09287f181..1af23fc26 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java @@ -10,6 +10,7 @@ import com.eternalcode.core.feature.chat.messages.ENChatMessages; import com.eternalcode.core.feature.clear.messages.ENClearMessages; import com.eternalcode.core.feature.container.messages.ENContainerMessages; +import com.eternalcode.core.feature.enderchest.messages.ENEnderchestMessages; import com.eternalcode.core.feature.deathteleport.messages.ENDeathTeleportMessages; import com.eternalcode.core.feature.deathmessage.messages.ENDeathMessages; import com.eternalcode.core.feature.disposal.messages.ENDisposalMessages; @@ -274,6 +275,9 @@ public static class ENFormatSection extends OkaeriConfig implements Format { @Comment("# Containers") public ENContainerMessages container = new ENContainerMessages(); + @Comment("# Ender chest") + public ENEnderchestMessages enderchest = new ENEnderchestMessages(); + @Comment("# Set slot") public ENSetSlotMessages setSlot = new ENSetSlotMessages(); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java index 67925fbaa..8d95848d7 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java @@ -9,6 +9,7 @@ import com.eternalcode.core.feature.playtime.messages.PLPlaytimeMessages; import com.eternalcode.core.feature.clear.messages.PLClearMessages; import com.eternalcode.core.feature.container.messages.PLContainerMessages; +import com.eternalcode.core.feature.enderchest.messages.PLEnderchestMessages; import com.eternalcode.core.feature.repair.messages.PLRepairMessages; import com.eternalcode.core.litecommand.argument.messages.PLArgumentMessages; import com.eternalcode.core.feature.adminchat.messages.PLAdminChatMessages; @@ -270,6 +271,9 @@ public static class PLFormatSection extends OkaeriConfig implements Format { @Comment("# Kontenery") public PLContainerMessages container = new PLContainerMessages(); + @Comment("# Enderchest") + public PLEnderchestMessages enderchest = new PLEnderchestMessages(); + @Comment("# Ustawianie slotów") public PLSetSlotMessages setSlot = new PLSetSlotMessages(); From 9d76c0215d75e4c57e0071ac7eba39e475aae913 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 16:59:00 +0200 Subject: [PATCH 2/8] Replace the enderchest writer thread with the shared repository executor --- .../feature/enderchest/EnderchestManager.java | 27 ++++++----- .../database/EnderchestRepository.java | 3 +- .../database/EnderchestRepositoryOrmLite.java | 45 +++---------------- 3 files changed, 24 insertions(+), 51 deletions(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java index b9013a7df..3a68ea0f6 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java @@ -1,10 +1,10 @@ package com.eternalcode.core.feature.enderchest; import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.core.database.DatabaseException; import com.eternalcode.core.feature.enderchest.database.EnderchestRepository; import com.eternalcode.core.injector.annotations.Inject; import com.eternalcode.core.injector.annotations.component.Service; -import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Set; @@ -21,7 +21,6 @@ @Service class EnderchestManager { - private static final Duration SHUTDOWN_WRITE_TIMEOUT = Duration.ofSeconds(10); private static final int MIN_PAGES = 1; private static final Predicate UNVIEWED = Predicate.not(Enderchest::hasViewers); @@ -75,6 +74,10 @@ boolean isLoaded(UUID ownerUniqueId, Enderchest enderchest) { } void saveEnderchest(Enderchest enderchest) { + if (enderchest.isWriting()) { + return; + } + EnderchestWrite write = enderchest.prepareWrite(); if (write.isEmpty()) { return; @@ -155,18 +158,20 @@ void shutdown() { continue; } - this.repository.savePages(enderchest.getOwnerUniqueId(), write) - .whenComplete((unused, throwable) -> { - enderchest.finishWrite(); + this.saveDuringShutdown(enderchest, write); + } + } - if (throwable != null) { - this.logger.log(Level.SEVERE, "Failed to save ender chest of " + enderchest.getOwnerName() - + " during shutdown, its changes are lost", throwable); - } - }); + private void saveDuringShutdown(Enderchest enderchest, EnderchestWrite write) { + try { + this.repository.savePagesNow(enderchest.getOwnerUniqueId(), write); + } + catch (DatabaseException exception) { + this.logger.log(Level.SEVERE, "Failed to save ender chest of " + enderchest.getOwnerName() + + " during shutdown, its changes are lost", exception); } - this.repository.shutdownWrites(SHUTDOWN_WRITE_TIMEOUT); + enderchest.finishWrite(); } private void unloadEnderchestIf(UUID ownerUniqueId, Predicate idle) { diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java index 388a5fec2..9ae7daf67 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java @@ -2,7 +2,6 @@ import com.eternalcode.core.feature.enderchest.EnderchestWrite; import com.eternalcode.core.feature.enderchest.PageContents; -import java.time.Duration; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -13,5 +12,5 @@ public interface EnderchestRepository { CompletableFuture savePages(UUID ownerUniqueId, EnderchestWrite write); - void shutdownWrites(Duration timeout); + void savePagesNow(UUID ownerUniqueId, EnderchestWrite write); } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java index 2146666df..ac6bba191 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java @@ -16,17 +16,11 @@ import com.j256.ormlite.support.ConnectionSource; import com.j256.ormlite.table.TableUtils; import java.sql.SQLException; -import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.logging.Logger; @Repository class EnderchestRepositoryOrmLite extends AbstractRepositoryOrmLite implements EnderchestRepository { @@ -39,18 +33,9 @@ class EnderchestRepositoryOrmLite extends AbstractRepositoryOrmLite implements E private static final Set WIDE_BLOB_TYPES = Set.of("mediumblob", "longblob"); - private final ExecutorService writer = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "EternalCore Enderchest Writer"); - thread.setDaemon(true); - return thread; - }); - - private final Logger logger; - @Inject - private EnderchestRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler, Logger logger) throws SQLException { + private EnderchestRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) throws SQLException { super(databaseManager, scheduler); - this.logger = logger; ConnectionSource connectionSource = databaseManager.connectionSource(); TableUtils.createTableIfNotExists(connectionSource, EnderchestPageTable.class); @@ -74,16 +59,14 @@ public CompletableFuture> findPages(UUID ownerUniqueId) { @Override public CompletableFuture savePages(UUID ownerUniqueId, EnderchestWrite write) { - try { - return CompletableFuture.runAsync(() -> this.writePages(ownerUniqueId, write), this.writer); - } - catch (RejectedExecutionException exception) { - return CompletableFuture.failedFuture( - new DatabaseException("Ender chest writes are already closed, cannot save pages of " + ownerUniqueId, exception)); - } + return this.action(EnderchestPageTable.class, dao -> { + this.writeInTransaction(dao, ownerUniqueId, write); + return null; + }); } - private void writePages(UUID ownerUniqueId, EnderchestWrite write) { + @Override + public void savePagesNow(UUID ownerUniqueId, EnderchestWrite write) { try { this.writeInTransaction(this.databaseManager.getDao(EnderchestPageTable.class), ownerUniqueId, write); } @@ -92,20 +75,6 @@ private void writePages(UUID ownerUniqueId, EnderchestWrite write) { } } - @Override - public void shutdownWrites(Duration timeout) { - this.writer.shutdown(); - - try { - if (!this.writer.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) { - this.logger.severe("Ender chest writes did not reach the database within " + timeout.toSeconds() + "s, the remaining ones are lost"); - } - } - catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - } - } - private void writeInTransaction(Dao dao, UUID ownerUniqueId, EnderchestWrite write) throws SQLException { boolean replaceAll = write.replaceAll(); From 02954d901aee915ab936d95de3f2b4189441874e Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 17:06:13 +0200 Subject: [PATCH 3/8] Shorten enderchest config comments --- .../feature/enderchest/EnderchestConfig.java | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java index 3dd4ab1e0..16c86d455 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java @@ -17,17 +17,11 @@ public class EnderchestConfig extends OkaeriConfig implements EnderchestSettings @Comment({ "# Replace the vanilla ender chest with the one from EternalCore, kept in the database", + "# This is the switch for the whole feature, with it off nothing else here does anything", "#", - "# This is the switch for the whole feature, not only for the extra pages. It turns on the custom", - "# inventory, the page limits per permission and everything in the 'pages' section below.", - "# With it off nothing else here does anything and players use their plain vanilla ender chest.", - "#", - "# WARNING: Make a backup of your database before you turn this on.", - "# After the next restart or /eternalcore reload everything from the vanilla ender chests is moved", - "# into the database and the vanilla ones are emptied. This cannot be undone.", - "# Online players are migrated right away, everyone else when they join the server.", - "#", - "# Turning it off later leaves the items in the database and players go back to the vanilla chest." + "# WARNING: Make a backup of your database first. On the next restart or /eternalcore reload", + "# the vanilla ender chests are moved into the database and emptied, this cannot be undone.", + "# Online players are migrated right away, everyone else when they join." }) public boolean replaceVanillaEnderchest = false; @@ -35,11 +29,10 @@ public class EnderchestConfig extends OkaeriConfig implements EnderchestSettings public boolean enderchestsBlocked = false; @Comment({ - "# Block more than one player from having the same ender chest open at once", - "# Opening one that somebody else is already viewing closes it for them and has to be repeated", + "# While somebody inspects an ender chest with /enderchest , its owner cannot open it", + "# Opening one that is already open closes it for the other player and has to be repeated", "#", - "# Keep this on when you run Folia, two viewers from different regions share one inventory there,", - "# which the server does not guard and which can duplicate or destroy items" + "# Recommended to keep on, two players sharing one ender chest can duplicate or lose items" }) public boolean sharedViewingBlocked = true; From 47dee628950071906127e2282bfe511e9238d186 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 17:10:36 +0200 Subject: [PATCH 4/8] rename command arg method --- .../eternalcode/core/feature/enderchest/EnderchestCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java index a77c27f83..3df1de550 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestCommand.java @@ -48,7 +48,7 @@ void execute(@Sender Player player) { @Execute @Permission("eternalcore.enderchest") @DescriptionDocs(description = "Opens selected page of your ender chest", arguments = "") - void execute(@Sender Player player, @Arg int page) { + void executePage(@Sender Player player, @Arg int page) { if (this.enderchestService.areEnderchestsBlocked()) { this.sendBlockedNotice(player); return; From 663bced8517d61d258992a40476cb58d61ff196f Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 17:18:23 +0200 Subject: [PATCH 5/8] Fix the ItemCodec comment to mention Paper instead of Bukkit --- .../core/feature/enderchest/database/ItemCodec.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java index 1c131389c..ca82c1733 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java @@ -7,8 +7,8 @@ import java.io.IOException; import org.bukkit.inventory.ItemStack; -// Bukkit only got serializeItemsAsBytes in 1.21.1, so we write every item on its own -// with serializeAsBytes to keep the same rows readable all the way down to 1.19.3. +// Paper only added serializeItemsAsBytes in 1.21.1, so we encode item by item to stay usable on 1.19.3. +// The per item serializeAsBytes we call here differs by one word, easy to mix the two up. final class ItemCodec { private static final byte FORMAT_VERSION = 2; From 933fae089b440244b15053ee1cc7f25e497c98ee Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 17:26:17 +0200 Subject: [PATCH 6/8] Move enderchest item serialization behind an ORMLite persister --- .../persister/ItemStackArrayPersister.java} | 63 ++++++++++++++++--- .../database/EnderchestPageTable.java | 13 ++-- 2 files changed, 60 insertions(+), 16 deletions(-) rename eternalcore-core/src/main/java/com/eternalcode/core/{feature/enderchest/database/ItemCodec.java => database/persister/ItemStackArrayPersister.java} (52%) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java similarity index 52% rename from eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java rename to eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java index ca82c1733..6876d83e9 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/ItemCodec.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java @@ -1,23 +1,66 @@ -package com.eternalcode.core.feature.enderchest.database; +package com.eternalcode.core.database.persister; +import com.j256.ormlite.field.FieldType; +import com.j256.ormlite.field.SqlType; +import com.j256.ormlite.field.types.BaseDataType; +import com.j256.ormlite.support.DatabaseResults; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.sql.SQLException; import org.bukkit.inventory.ItemStack; // Paper only added serializeItemsAsBytes in 1.21.1, so we encode item by item to stay usable on 1.19.3. // The per item serializeAsBytes we call here differs by one word, easy to mix the two up. -final class ItemCodec { +public class ItemStackArrayPersister extends BaseDataType { + + private static final ItemStackArrayPersister INSTANCE = new ItemStackArrayPersister(); private static final byte FORMAT_VERSION = 2; private static final int EMPTY_SLOT = -1; private static final int MAX_SLOTS = 1024; - private ItemCodec() {} + private ItemStackArrayPersister() { + super(SqlType.BYTE_ARRAY, new Class[] { ItemStack[].class }); + } + + @Override + public Object javaToSqlArg(FieldType fieldType, Object javaObject) { + return encode((ItemStack[]) javaObject); + } + + @Override + public Object resultToSqlArg(FieldType fieldType, DatabaseResults results, int columnPos) throws SQLException { + return results.getBytes(columnPos); + } + + @Override + public Object sqlArgToJava(FieldType fieldType, Object sqlArg, int columnPos) { + return decode((byte[]) sqlArg); + } + + @Override + public Object parseDefaultString(FieldType fieldType, String defaultStr) throws SQLException { + throw new SQLException("Items cannot be given a default value"); + } + + @Override + public boolean isArgumentHolderRequired() { + return true; + } + + @Override + public Class getPrimaryClass() { + return ItemStack[].class; + } + + public static ItemStackArrayPersister getSingleton() { + return INSTANCE; + } - static byte[] encode(ItemStack[] items) { + private static byte[] encode(ItemStack[] items) { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try (DataOutputStream output = new DataOutputStream(buffer)) { @@ -36,13 +79,13 @@ static byte[] encode(ItemStack[] items) { } } catch (IOException exception) { - throw new IllegalStateException("Failed to encode ender chest items", exception); + throw new IllegalStateException("Failed to encode items", exception); } return buffer.toByteArray(); } - static ItemStack[] decode(byte[] bytes) { + private static ItemStack[] decode(byte[] bytes) { if (bytes == null || bytes.length == 0) { return new ItemStack[0]; } @@ -50,12 +93,12 @@ static ItemStack[] decode(byte[] bytes) { try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { byte version = input.readByte(); if (version != FORMAT_VERSION) { - throw new IllegalStateException("Unknown ender chest item format: " + version); + throw new IllegalStateException("Unknown item format: " + version); } int slots = input.readInt(); if (slots < 0 || slots > MAX_SLOTS) { - throw new IllegalStateException("Ender chest page declares " + slots + " slots"); + throw new IllegalStateException("Item array declares " + slots + " slots"); } ItemStack[] items = new ItemStack[slots]; @@ -66,7 +109,7 @@ static ItemStack[] decode(byte[] bytes) { } if (length < 0 || length > bytes.length) { - throw new IllegalStateException("Ender chest slot " + slot + " declares " + length + " bytes"); + throw new IllegalStateException("Slot " + slot + " declares " + length + " bytes"); } byte[] serialized = new byte[length]; @@ -77,7 +120,7 @@ static ItemStack[] decode(byte[] bytes) { return items; } catch (IOException exception) { - throw new IllegalStateException("Failed to decode ender chest items", exception); + throw new IllegalStateException("Failed to decode items", exception); } } } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java index 5d8453dfd..64089a5f3 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java @@ -1,10 +1,11 @@ package com.eternalcode.core.feature.enderchest.database; +import com.eternalcode.core.database.persister.ItemStackArrayPersister; import com.eternalcode.core.feature.enderchest.PageContents; -import com.j256.ormlite.field.DataType; import com.j256.ormlite.field.DatabaseField; import com.j256.ormlite.table.DatabaseTable; import java.util.UUID; +import org.bukkit.inventory.ItemStack; @DatabaseTable(tableName = EnderchestPageTable.TABLE_NAME) class EnderchestPageTable { @@ -23,12 +24,12 @@ class EnderchestPageTable { @DatabaseField(columnName = PAGE_COLUMN) private int page; - @DatabaseField(columnName = CONTENTS_COLUMN, dataType = DataType.BYTE_ARRAY) - private byte[] contents; + @DatabaseField(columnName = CONTENTS_COLUMN, persisterClass = ItemStackArrayPersister.class) + private ItemStack[] contents; EnderchestPageTable() {} - private EnderchestPageTable(UUID ownerUniqueId, int page, byte[] contents) { + private EnderchestPageTable(UUID ownerUniqueId, int page, ItemStack[] contents) { this.id = idOf(ownerUniqueId, page); this.ownerUniqueId = ownerUniqueId; this.page = page; @@ -40,10 +41,10 @@ static String idOf(UUID ownerUniqueId, int page) { } static EnderchestPageTable from(UUID ownerUniqueId, PageContents contents) { - return new EnderchestPageTable(ownerUniqueId, contents.page(), ItemCodec.encode(contents.items())); + return new EnderchestPageTable(ownerUniqueId, contents.page(), contents.items()); } PageContents toContents() { - return new PageContents(this.page, ItemCodec.decode(this.contents)); + return new PageContents(this.page, this.contents); } } From 7250b977dd5fd2d70fe5a77cda107f4a383ad6f8 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 17:47:50 +0200 Subject: [PATCH 7/8] Fix lost writes and premature item access during enderchest import --- .../database/persister/ItemStackArrayPersister.java | 6 +++++- .../core/feature/enderchest/Enderchest.java | 13 +++++++++++++ .../feature/enderchest/EnderchestInventory.java | 5 +++++ .../core/feature/enderchest/EnderchestManager.java | 11 +++++++++-- .../enderchest/VanillaEnderchestMigration.java | 10 +++------- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java index 6876d83e9..e2e3bc4e0 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java @@ -86,10 +86,14 @@ private static byte[] encode(ItemStack[] items) { } private static ItemStack[] decode(byte[] bytes) { - if (bytes == null || bytes.length == 0) { + if (bytes == null) { return new ItemStack[0]; } + if (bytes.length == 0) { + throw new IllegalStateException("Stored items are empty, the row is corrupted"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { byte version = input.readByte(); if (version != FORMAT_VERSION) { diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java index 87e0b866b..d92fa16af 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java @@ -20,6 +20,7 @@ class Enderchest { private String ownerName; private UUID viewerUniqueId; + private boolean importing; private ItemStack[] slots; private boolean rewriteRequired; private int viewers; @@ -253,6 +254,18 @@ boolean isPersisted() { return !this.rewriteRequired && this.dirtyPages.isEmpty() && this.pendingWrite.isDone(); } + boolean isImporting() { + return this.importing; + } + + void beginImport() { + this.importing = true; + } + + void finishImport() { + this.importing = false; + } + boolean isWriting() { return !this.pendingWrite.isDone(); } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java index 664a90488..dd401c8ff 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java @@ -118,6 +118,11 @@ private void openLoadedPage(Player viewer, Enderchest enderchest, int page) { return; } + if (enderchest.isImporting()) { + this.sendEnderchestInUseNotice(viewer, enderchest); + return; + } + if (!viewer.getUniqueId().equals(ownerUniqueId) && enderchest.getUsedPages() == 0) { this.sendEnderchestEmptyNotice(viewer, enderchest); this.enderchestManager.unloadIdleEnderchest(ownerUniqueId); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java index 3a68ea0f6..43e820f4d 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java @@ -88,9 +88,16 @@ void saveEnderchest(Enderchest enderchest) { this.applyWriteResult(enderchest, write, throwable); enderchest.finishWrite(); - if (enderchest.hasNothingToWrite()) { - this.unloadIdleEnderchest(enderchest.getOwnerUniqueId()); + if (throwable != null) { + return; } + + if (!enderchest.hasNothingToWrite()) { + this.saveEnderchest(enderchest); + return; + } + + this.unloadIdleEnderchest(enderchest.getOwnerUniqueId()); })); } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java index 9169e8066..88435eb3c 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java @@ -5,9 +5,7 @@ import com.eternalcode.core.feature.enderchest.database.EnderchestRepository; import com.eternalcode.core.injector.annotations.Inject; import com.eternalcode.core.injector.annotations.component.Service; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.Server; @@ -18,8 +16,6 @@ @Service class VanillaEnderchestMigration { - private final Set runningImports = ConcurrentHashMap.newKeySet(); - private final EnderchestManager enderchestManager; private final EnderchestRepository repository; private final EnderchestSettings settings; @@ -67,7 +63,7 @@ private void importVanillaItems(Player player, Enderchest enderchest) { return; } - if (enderchest.hasViewers() || enderchest.isWriting() || this.runningImports.contains(ownerUniqueId)) { + if (enderchest.hasViewers() || enderchest.isWriting() || enderchest.isImporting()) { return; } @@ -95,12 +91,12 @@ private void importVanillaItems(Player player, Enderchest enderchest) { return; } - this.runningImports.add(ownerUniqueId); + enderchest.beginImport(); this.repository.savePages(ownerUniqueId, write) .whenComplete((unused, throwable) -> this.scheduler.run(() -> { this.applyImportResult(player, enderchest, write, filledSlots, throwable); - this.runningImports.remove(ownerUniqueId); + enderchest.finishImport(); enderchest.finishWrite(); this.enderchestManager.unloadUnviewedEnderchest(ownerUniqueId); })); From 5c359f8146bff9b4c754ebb2f9b385a67dff04c9 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 21 Sep 2026 18:06:18 +0200 Subject: [PATCH 8/8] Store only non-empty slots, like vanilla does --- .../persister/ItemStackArrayPersister.java | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java index e2e3bc4e0..b95c4d3f4 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java @@ -18,8 +18,7 @@ public class ItemStackArrayPersister extends BaseDataType { private static final ItemStackArrayPersister INSTANCE = new ItemStackArrayPersister(); - private static final byte FORMAT_VERSION = 2; - private static final int EMPTY_SLOT = -1; + private static final byte FORMAT_VERSION = 3; private static final int MAX_SLOTS = 1024; private ItemStackArrayPersister() { @@ -66,14 +65,16 @@ private static byte[] encode(ItemStack[] items) { try (DataOutputStream output = new DataOutputStream(buffer)) { output.writeByte(FORMAT_VERSION); output.writeInt(items.length); + output.writeInt(countStored(items)); - for (ItemStack item : items) { + for (int slot = 0; slot < items.length; slot++) { + ItemStack item = items[slot]; if (item == null) { - output.writeInt(EMPTY_SLOT); continue; } byte[] serialized = item.serializeAsBytes(); + output.writeInt(slot); output.writeInt(serialized.length); output.write(serialized); } @@ -85,6 +86,18 @@ private static byte[] encode(ItemStack[] items) { return buffer.toByteArray(); } + private static int countStored(ItemStack[] items) { + int stored = 0; + + for (ItemStack item : items) { + if (item != null) { + stored++; + } + } + + return stored; + } + private static ItemStack[] decode(byte[] bytes) { if (bytes == null) { return new ItemStack[0]; @@ -105,14 +118,20 @@ private static ItemStack[] decode(byte[] bytes) { throw new IllegalStateException("Item array declares " + slots + " slots"); } + int stored = input.readInt(); + if (stored < 0 || stored > slots) { + throw new IllegalStateException("Item array declares " + stored + " items in " + slots + " slots"); + } + ItemStack[] items = new ItemStack[slots]; - for (int slot = 0; slot < slots; slot++) { - int length = input.readInt(); - if (length == EMPTY_SLOT) { - continue; + for (int index = 0; index < stored; index++) { + int slot = input.readInt(); + if (slot < 0 || slot >= slots) { + throw new IllegalStateException("Item points at slot " + slot + " outside of " + slots); } - if (length < 0 || length > bytes.length) { + int length = input.readInt(); + if (length <= 0 || length > bytes.length) { throw new IllegalStateException("Slot " + slot + " declares " + length + " bytes"); }