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/database/persister/ItemStackArrayPersister.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java new file mode 100644 index 000000000..b95c4d3f4 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/persister/ItemStackArrayPersister.java @@ -0,0 +1,149 @@ +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. +public class ItemStackArrayPersister extends BaseDataType { + + private static final ItemStackArrayPersister INSTANCE = new ItemStackArrayPersister(); + + private static final byte FORMAT_VERSION = 3; + private static final int MAX_SLOTS = 1024; + + 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; + } + + private static byte[] encode(ItemStack[] items) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + try (DataOutputStream output = new DataOutputStream(buffer)) { + output.writeByte(FORMAT_VERSION); + output.writeInt(items.length); + output.writeInt(countStored(items)); + + for (int slot = 0; slot < items.length; slot++) { + ItemStack item = items[slot]; + if (item == null) { + continue; + } + + byte[] serialized = item.serializeAsBytes(); + output.writeInt(slot); + output.writeInt(serialized.length); + output.write(serialized); + } + } + catch (IOException exception) { + throw new IllegalStateException("Failed to encode items", exception); + } + + 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]; + } + + 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) { + throw new IllegalStateException("Unknown item format: " + version); + } + + int slots = input.readInt(); + if (slots < 0 || slots > MAX_SLOTS) { + 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 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); + } + + int length = input.readInt(); + if (length <= 0 || length > bytes.length) { + throw new IllegalStateException("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 items", exception); + } + } +} 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..d92fa16af --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/Enderchest.java @@ -0,0 +1,321 @@ +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 boolean importing; + 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 isImporting() { + return this.importing; + } + + void beginImport() { + this.importing = true; + } + + void finishImport() { + this.importing = false; + } + + 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..3df1de550 --- /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 executePage(@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..16c86d455 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestConfig.java @@ -0,0 +1,119 @@ +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, with it off nothing else here does anything", + "#", + "# 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; + + @Comment("# Take ender chests away from everyone, vanilla and custom alike") + public boolean enderchestsBlocked = false; + + @Comment({ + "# 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", + "#", + "# Recommended to keep on, two players sharing one ender chest can duplicate or lose 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..dd401c8ff --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestInventory.java @@ -0,0 +1,423 @@ +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 (enderchest.isImporting()) { + this.sendEnderchestInUseNotice(viewer, enderchest); + 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..43e820f4d --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/EnderchestManager.java @@ -0,0 +1,231 @@ +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.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 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) { + if (enderchest.isWriting()) { + return; + } + + 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 (throwable != null) { + return; + } + + if (!enderchest.hasNothingToWrite()) { + this.saveEnderchest(enderchest); + return; + } + + 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.saveDuringShutdown(enderchest, write); + } + } + + 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); + } + + enderchest.finishWrite(); + } + + 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..88435eb3c --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/VanillaEnderchestMigration.java @@ -0,0 +1,126 @@ +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.UUID; +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 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() || enderchest.isImporting()) { + 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; + } + + enderchest.beginImport(); + + this.repository.savePages(ownerUniqueId, write) + .whenComplete((unused, throwable) -> this.scheduler.run(() -> { + this.applyImportResult(player, enderchest, write, filledSlots, throwable); + enderchest.finishImport(); + 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..64089a5f3 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestPageTable.java @@ -0,0 +1,50 @@ +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.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.util.UUID; +import org.bukkit.inventory.ItemStack; + +@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, persisterClass = ItemStackArrayPersister.class) + private ItemStack[] contents; + + EnderchestPageTable() {} + + private EnderchestPageTable(UUID ownerUniqueId, int page, ItemStack[] 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(), contents.items()); + } + + PageContents toContents() { + return new PageContents(this.page, 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..9ae7daf67 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepository.java @@ -0,0 +1,16 @@ +package com.eternalcode.core.feature.enderchest.database; + +import com.eternalcode.core.feature.enderchest.EnderchestWrite; +import com.eternalcode.core.feature.enderchest.PageContents; +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 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 new file mode 100644 index 000000000..ac6bba191 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/enderchest/database/EnderchestRepositoryOrmLite.java @@ -0,0 +1,125 @@ +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.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@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"); + + @Inject + private EnderchestRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) throws SQLException { + super(databaseManager, scheduler); + + 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) { + return this.action(EnderchestPageTable.class, dao -> { + this.writeInTransaction(dao, ownerUniqueId, write); + return null; + }); + } + + @Override + public void savePagesNow(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); + } + } + + 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/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();