diff --git a/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryModifyCommand.java b/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryModifyCommand.java new file mode 100644 index 00000000..33003fa3 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryModifyCommand.java @@ -0,0 +1,146 @@ +package org.mvplugins.multiverse.inventories.commands; + +import com.dumptruckman.minecraft.util.Logging; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.command.MVCommandIssuer; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandCompletion; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandPermission; +import org.mvplugins.multiverse.external.acf.commands.annotation.Description; +import org.mvplugins.multiverse.external.acf.commands.annotation.Flags; +import org.mvplugins.multiverse.external.acf.commands.annotation.Subcommand; +import org.mvplugins.multiverse.external.acf.commands.annotation.Syntax; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.inventories.MultiverseInventories; +import org.mvplugins.multiverse.inventories.view.InventoryDataProvider; +import org.mvplugins.multiverse.inventories.view.InventoryGUIHelper; +import org.mvplugins.multiverse.inventories.view.ModifiableInventoryHolder; +import org.mvplugins.multiverse.inventories.view.PlayerInventoryData; + +@Service +final class InventoryModifyCommand extends InventoriesCommand { + + private final InventoryDataProvider inventoryDataProvider; + private final MultiverseInventories inventories; + private final InventoryGUIHelper inventoryGUIHelper; + + @Inject + InventoryModifyCommand( + @NotNull InventoryDataProvider inventoryDataProvider, + @NotNull MultiverseInventories inventories, + @NotNull InventoryGUIHelper inventoryGUIHelper + ) { + this.inventoryDataProvider = inventoryDataProvider; + this.inventories = inventories; + this.inventoryGUIHelper = inventoryGUIHelper; + } + + // This method contains the logic for the /mvinv modify command + @Subcommand("modify") + @CommandPermission("multiverse.inventories.view.modify") // Specific permission for modification + @CommandCompletion("@mvinvplayernames @mvworlds") + @Syntax(" ") + @Description("Modify a player's inventory in a specific world.") + void onInventoryModifyCommand( + @NotNull MVCommandIssuer issuer, + + // to make sure the command is only run by players + @Flags("resolve=issuerOnly") + @NotNull Player player, + + @Syntax("") + @Description("Online or offline player") + OfflinePlayer targetPlayer, + + @Syntax("") + @Description("The world the player's inventory is in") + MultiverseWorld world + ) { + String worldName = world.getName(); + issuer.sendInfo(ChatColor.YELLOW + "Loading inventory data for " + targetPlayer.getName() + "..."); + handleInventoryLoadAndDisplay(issuer, player, targetPlayer, worldName); + } + + /** + * Handles the asynchronous loading of player inventory data and the display of the GUI. + * + * @param issuer The command issuer. + * @param player The player who will view/modify the inventory. + * @param targetPlayer The offline or online player whose inventory data is being loaded. + * @param worldName The name of the world for which inventory data is loaded. + */ + private void handleInventoryLoadAndDisplay( + @NotNull MVCommandIssuer issuer, + @NotNull Player player, + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName + ) { + inventoryDataProvider.loadPlayerInventoryData(targetPlayer, worldName) + .thenAccept(playerInventoryData -> { + // Ensure GUI operations run on the main thread + Bukkit.getScheduler().runTask(inventories, () -> { + + // If the player tries to modify their own live inventory, stop + if (player.getUniqueId().equals(targetPlayer.getUniqueId()) + && player.getWorld().getName().equalsIgnoreCase(worldName)) { + issuer.sendError(ChatColor.RED + "You cannot modify your own live inventory using this command. Use your regular inventory."); + return; // Stop here if it's a live self-inventory + } + createAndOpenGUI(issuer, player, targetPlayer, worldName, playerInventoryData); + }); + }) + + + .exceptionally(throwable -> { + // This block runs if an exception occurs during data loading + String errorMessage = throwable.getMessage(); + if (errorMessage == null || errorMessage.isEmpty()) { + errorMessage = "An unknown error occurred while loading inventory data."; + } + issuer.sendError(ChatColor.RED + errorMessage); + Logging.fine("Error loading inventory for " + targetPlayer.getName() + ": " + throwable.getMessage()); + throwable.printStackTrace(); + return null; + }); + } + + /** + * Creates and opens the custom inventory GUI for modification. + * This method must be called on the main server thread. + * + * @param issuer The command issuer. + * @param player The player who will view/modify the inventory. + * @param targetPlayer The offline player whose inventory is being displayed. + * @param worldName The name of the world for which inventory data is displayed. + * @param playerInventoryData The loaded inventory data. + */ + private void createAndOpenGUI( + @NotNull MVCommandIssuer issuer, + @NotNull Player player, + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull PlayerInventoryData playerInventoryData + ) { + String title = "Modify " + targetPlayer.getName() + " @ " + worldName; + Inventory inv = Bukkit.createInventory( + new ModifiableInventoryHolder( + targetPlayer, + worldName, + playerInventoryData.profileTypeUsed // Use the determined profile type + ), + 45, // 5 rows for 36 main + 4 armor + 1 off-hand + 4 fillers + title + ); + + // Call the helper method to populate the GUI + inventoryGUIHelper.populateInventoryGUI(inv, playerInventoryData, true); + player.openInventory(inv); + issuer.sendInfo(ChatColor.GREEN + playerInventoryData.status.getFormattedMessage(targetPlayer.getName(), worldName) + ". Changes will save on close."); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryViewCommand.java b/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryViewCommand.java new file mode 100644 index 00000000..27cc2a6e --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/commands/InventoryViewCommand.java @@ -0,0 +1,128 @@ +package org.mvplugins.multiverse.inventories.commands; + +import com.dumptruckman.minecraft.util.Logging; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.command.MVCommandIssuer; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandCompletion; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandPermission; +import org.mvplugins.multiverse.external.acf.commands.annotation.Description; +import org.mvplugins.multiverse.external.acf.commands.annotation.Flags; +import org.mvplugins.multiverse.external.acf.commands.annotation.Subcommand; +import org.mvplugins.multiverse.external.acf.commands.annotation.Syntax; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.inventories.MultiverseInventories; +import org.mvplugins.multiverse.inventories.view.InventoryGUIHelper; +import org.mvplugins.multiverse.inventories.view.PlayerInventoryData; +import org.mvplugins.multiverse.inventories.view.ReadOnlyInventoryHolder; +import org.mvplugins.multiverse.inventories.view.InventoryDataProvider; + +@Service +final class InventoryViewCommand extends InventoriesCommand { + + private final InventoryDataProvider inventoryDataProvider; + private final MultiverseInventories inventories; + private final InventoryGUIHelper inventoryGUIHelper; + + @Inject + InventoryViewCommand( + @NotNull InventoryDataProvider inventoryDataProvider, + @NotNull MultiverseInventories inventories, + @NotNull InventoryGUIHelper inventoryGUIHelper + ) { + this.inventories = inventories; + this.inventoryDataProvider = inventoryDataProvider; + this.inventoryGUIHelper = inventoryGUIHelper; + } + + @Subcommand("view") + @CommandPermission("multiverse.inventories.view") + @CommandCompletion("@mvinvplayernames @mvworlds") + @Syntax(" ") + @Description("View a player's inventory in a specific world.") + void onInventoryViewCommand( + @NotNull MVCommandIssuer issuer, + + // Ensure the command is run by a player + @Flags("resolve=issuerOnly") + @NotNull Player player, + + @Syntax("") + @Description("Online or offline player") + OfflinePlayer targetPlayer, + + @Syntax("") + @Description("The world the player's inventory is in") + MultiverseWorld world + ) { + String worldName = world.getName(); + + // Asynchronously load data using InventoryDataProvider + issuer.sendInfo(ChatColor.YELLOW + "Loading inventory data for " + targetPlayer.getName() + "..."); + handleInventoryLoadAndDisplay(issuer, player, targetPlayer, worldName); + } + + /** + * Handles the asynchronous loading of player inventory data and the display of the GUI. + * + * @param issuer The command issuer. + * @param player The player who will view the inventory. + * @param targetPlayer The offline player whose inventory data is being loaded. + * @param worldName The name of the world for which inventory data is loaded. + */ + private void handleInventoryLoadAndDisplay( + @NotNull MVCommandIssuer issuer, + @NotNull Player player, + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName) { + inventoryDataProvider.loadPlayerInventoryData(targetPlayer, worldName) + .thenAccept(playerInventoryData -> { + // Ensure GUI operations run on the main thread + Bukkit.getScheduler().runTask(inventories, () -> { + createAndOpenGUI(issuer, player, targetPlayer, worldName, playerInventoryData); + }); // End of Bukkit.getScheduler().runTask() + }) + .exceptionally(throwable -> { + // This block runs if an exception occurs during data loading + issuer.sendError(ChatColor.RED + "Failed to load inventory data: " + throwable.getMessage()); + Logging.severe("Error loading inventory for " + targetPlayer.getName() + ": " + throwable.getMessage()); + throwable.printStackTrace(); + return null; // Must return null for CompletableFuture in exceptionally + }); + } + + /** + * Creates and opens the custom inventory GUI for viewing. + * This method must be called on the main server thread. + * + * @param issuer The command issuer. + * @param player The player who will view/modify the inventory. + * @param targetPlayer The offline player whose inventory is being displayed. + * @param worldName The name of the world for which inventory data is displayed. + * @param playerInventoryData The loaded inventory data. + */ + private void createAndOpenGUI( + @NotNull MVCommandIssuer issuer, + @NotNull Player player, + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull PlayerInventoryData playerInventoryData + ) { + String title = targetPlayer.getName() + " @ " + worldName; + Inventory inv = Bukkit.createInventory(new ReadOnlyInventoryHolder(), + 45, // 5 rows for 36 main + 4 armor + 1 off-hand + 4 fillers + title + ); + + // Call the helper method to populate the GUI + inventoryGUIHelper.populateInventoryGUI(inv, playerInventoryData, false); + player.openInventory(inv); + issuer.sendInfo(ChatColor.GREEN + playerInventoryData.status.getFormattedMessage(targetPlayer.getName(), worldName) + ". Changes will save on close."); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/listeners/InventoryViewListener.java b/src/main/java/org/mvplugins/multiverse/inventories/listeners/InventoryViewListener.java new file mode 100644 index 00000000..123ca412 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/listeners/InventoryViewListener.java @@ -0,0 +1,237 @@ +package org.mvplugins.multiverse.inventories.listeners; + +import com.dumptruckman.minecraft.util.Logging; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.event.EventPriority; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.dynamiclistener.annotations.DefaultEventPriority; +import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.inventories.MultiverseInventories; +import org.mvplugins.multiverse.inventories.view.InventoryDataProvider; +import org.mvplugins.multiverse.inventories.profile.key.ProfileType; +import org.mvplugins.multiverse.inventories.view.InventoryGUIHelper; +import org.mvplugins.multiverse.inventories.view.ModifiableInventoryHolder; +import org.mvplugins.multiverse.inventories.view.ReadOnlyInventoryHolder; + +import java.util.Arrays; + +@Service +final class InventoryViewListener implements MVInvListener { + + private final MultiverseInventories inventories; + private final InventoryDataProvider inventoryDataProvider; + private final InventoryGUIHelper inventoryGUIHelper; + + @Inject + InventoryViewListener( + @NotNull MultiverseInventories inventories, + @NotNull InventoryDataProvider inventoryDataProvider, + @NotNull InventoryGUIHelper inventoryGUIHelper + ) { + this.inventories = inventories; + this.inventoryDataProvider = inventoryDataProvider; + this.inventoryGUIHelper = inventoryGUIHelper; + } + + // This listener will cancel any clicks or drags in inventories that have the ReadOnlyInventoryHolder marker. + @EventMethod + @DefaultEventPriority(EventPriority.NORMAL) + void onInventoryClick(InventoryClickEvent event) { + // If it's a read-only inventory, cancel all clicks. + if (event.getInventory().getHolder() instanceof ReadOnlyInventoryHolder || + (event.getClickedInventory() != null && event.getClickedInventory().getHolder() instanceof ReadOnlyInventoryHolder)) { + event.setCancelled(true); + return; + } + + // If it's a modifiable inventory, apply specific restrictions for armor/off-hand slots. + if (!(event.getInventory().getHolder() instanceof ModifiableInventoryHolder)) { + return; + } + + int clickedSlot = event.getRawSlot(); + ItemStack cursorItem = event.getCursor(); // Item held by the cursor + ItemStack currentItem = event.getCurrentItem(); // Item in the clicked slot + Player player = (Player) event.getWhoClicked(); // The player who clicked + + // Define the special slots + boolean isSpecialSlot = (clickedSlot >= 36 && clickedSlot <= 40); // Armor (36-39) and Off-hand (40) + + // Determine if the slot is one of the padding slots (41-44) + boolean isPaddingSlot = (clickedSlot >= 41 && clickedSlot <= 44); + + if (isPaddingSlot) { + // Clicks on padding slots are always cancelled and do nothing else. + event.setCancelled(true); + return; + } + + // --- Logic for special slots (armor/off-hand) --- + if (!isSpecialSlot) { + return; + } + + boolean currentItemIsFiller = currentItem != null && inventoryGUIHelper.isFillerItem(currentItem); + + // Scenario 1: Player tries to pick up a filler item (cursor is empty) + if (currentItemIsFiller && (cursorItem == null || cursorItem.getType() == Material.AIR)) { + event.setCancelled(true); + return; // Prevent pickup, filler stays in place + } + + // Scenario 2: Player tries to place an invalid item into a special slot + if (cursorItem != null && cursorItem.getType() != Material.AIR && !inventoryGUIHelper.isValidItemForSlot(cursorItem, clickedSlot)) { + event.setCancelled(true); + return; // Prevent invalid placement + } + + // Scenario 3: Player places a valid item into a special slot that currently holds a filler or a valid item. + // Manually handle the swap to ensure fillers don't leave the GUI. + if (cursorItem != null && cursorItem.getType() != Material.AIR && inventoryGUIHelper.isValidItemForSlot(cursorItem, clickedSlot)) { + event.setCancelled(true); // Take full control of the event + + // Place the new item from the cursor into the clicked slot + event.getInventory().setItem(clickedSlot, cursorItem); + + // If the player tries to replace an item in the filler slot, check if the item is valid. + if (currentItem != null && currentItem.getType() != Material.AIR && !inventoryGUIHelper.isFillerItem(currentItem)) { + // If the original item was a real, non-filler item, put it on the player's cursor. + player.setItemOnCursor(currentItem); + } else { + // If the original item was null, air, or a filler, clear the player's cursor. + player.setItemOnCursor(null); + } + + player.updateInventory(); // Update client to reflect changes + return; // Event handled + } + + // Scenario 4: Player is shift-clicking a valid item from a special slot. + // Or picking up a valid item from a special slot. + // We need to ensure filler reappears if the slot becomes empty. + if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY || + event.getAction() == InventoryAction.PICKUP_ALL || + event.getAction() == InventoryAction.PICKUP_HALF || + event.getAction() == InventoryAction.PICKUP_ONE || + event.getAction() == InventoryAction.PICKUP_SOME) { + Bukkit.getScheduler().runTaskLater(inventories, () -> { + // After Bukkit processes the click, if the slot is now empty, put the filler back. + if (event.getInventory().getItem(clickedSlot) == null || event.getInventory().getItem(clickedSlot).getType() == Material.AIR) { + event.getInventory().setItem(clickedSlot, inventoryGUIHelper.createFillerItemForSlot(clickedSlot, true)); + } + }, 1L); + } + } + + // Also cancel drag events to prevent items from being dragged into/out of the inventory + @EventMethod + @DefaultEventPriority(EventPriority.NORMAL) + void onInventoryDrag(InventoryDragEvent event) { + // If it is a read-only inventory, cancel all drags + if (event.getInventory().getHolder() instanceof ReadOnlyInventoryHolder) { + event.setCancelled(true); + } + + // If it's a modifiable inventory, apply specific restrictions for armor/off-hand slots. + if (!(event.getInventory().getHolder() instanceof ModifiableInventoryHolder)) { + return; + } + + ItemStack draggedItem = event.getCursor(); // The item being dragged (after the drag operation) + + // If nothing is being dragged, or dragging air, no restriction needed. + if (draggedItem == null || draggedItem.getType() == Material.AIR) { + return; + } + + for (int slot : event.getRawSlots()) { + // Define the special slots + boolean isSpecialSlot = (slot >= 36 && slot <= 40); + + // Determine if the slot is one of the padding slots (41-44) + boolean isPaddingSlot = (slot >= 41 && slot <= 44); + + if (isPaddingSlot) { + // Clicks on padding slots are always cancelled and do nothing else. + event.setCancelled(true); + return; + } + + // Check if the dragged item is valid for this special slot + if (isSpecialSlot && !inventoryGUIHelper.isValidItemForSlot(draggedItem, slot)) { + event.setCancelled(true); + return; // Cancel the entire drag event + } + } + + // After a drag, check if any special slots became empty and replace with filler + Bukkit.getScheduler().runTaskLater(inventories, () -> { + for (int slot : event.getRawSlots()) { + if ((slot >= 36 && slot <= 40) && (event.getInventory().getItem(slot) == null || event.getInventory().getItem(slot).getType() == Material.AIR)) { + event.getInventory().setItem(slot, inventoryGUIHelper.createFillerItemForSlot(slot, true)); // Use helper + } + } + }, 1L); // Run one tick later + } + + // Event handler for InventoryCloseEvent to save changes + @EventMethod + @DefaultEventPriority(EventPriority.NORMAL) + void onInventoryClose(InventoryCloseEvent event) { + // Check if the closed inventory has the custom ModifiableInventoryHolder class + if (!(event.getInventory().getHolder() instanceof ModifiableInventoryHolder holder)) { + return; + } + + final OfflinePlayer targetPlayer = holder.getTargetPlayer(); + final String worldName = holder.getWorldName(); + final ProfileType profileType = holder.getProfileType(); + final Inventory closedInventory = event.getInventory(); + + // Extract contents: 0-35 for inventory, 36-39 for armor, 40 for off-hand + ItemStack[] newContents = Arrays.copyOfRange(closedInventory.getContents(), 0, 36); + ItemStack[] newArmor = new ItemStack[4]; + // Map GUI armor slots back to Multiverse-Inventories' expected order [helmet, chestplate, leggings, boots] + newArmor[3] = closedInventory.getItem(36); // Helmet + newArmor[2] = closedInventory.getItem(37); // Chestplate + newArmor[1] = closedInventory.getItem(38); // Leggings + newArmor[0] = closedInventory.getItem(39); // Boots + ItemStack newOffHand = closedInventory.getItem(40); + + // Before saving, ensure any filler items are removed from the actual data + for (int i = 0; i < newArmor.length; i++) { + if (newArmor[i] != null && inventoryGUIHelper.isFillerItem(newArmor[i])) { + newArmor[i] = null; // Replace filler with null for saving + } + } + if (newOffHand != null && inventoryGUIHelper.isFillerItem(newOffHand)) { + newOffHand = null; // Replace filler with null for saving + } + + // Delegate saving to InventoryDataProvider + inventoryDataProvider.savePlayerInventoryData( + targetPlayer, + worldName, + profileType, + newContents, + newArmor, + newOffHand + ).exceptionally(throwable -> { + // Error logging is now handled within InventoryDataProvider, but we can add a general one here too + Logging.severe("Error during inventory save process for " + targetPlayer.getName() + ": " + throwable.getMessage()); + throwable.printStackTrace(); + return null; + }); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/listeners/MVInvListener.java b/src/main/java/org/mvplugins/multiverse/inventories/listeners/MVInvListener.java index 6e760c37..88518e82 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/listeners/MVInvListener.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/listeners/MVInvListener.java @@ -2,7 +2,8 @@ import org.jvnet.hk2.annotations.Contract; import org.mvplugins.multiverse.core.dynamiclistener.DynamicListener; +import org.mvplugins.multiverse.inventories.view.ReadOnlyInventoryHolder; @Contract -public sealed interface MVInvListener extends DynamicListener permits MVEventsListener, RespawnListener, ShareHandleListener, SpawnChangeListener { +public sealed interface MVInvListener extends DynamicListener permits InventoryViewListener, MVEventsListener, RespawnListener, ShareHandleListener, SpawnChangeListener { } diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryDataProvider.java b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryDataProvider.java new file mode 100644 index 00000000..d47c1ce9 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryDataProvider.java @@ -0,0 +1,324 @@ +package org.mvplugins.multiverse.inventories.view; + +import com.dumptruckman.minecraft.util.Logging; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.external.jetbrains.annotations.Nullable; +import org.mvplugins.multiverse.inventories.MultiverseInventories; +import org.mvplugins.multiverse.inventories.config.InventoriesConfig; +import org.mvplugins.multiverse.inventories.handleshare.SingleShareReader; +import org.mvplugins.multiverse.inventories.handleshare.SingleShareWriter; +import org.mvplugins.multiverse.inventories.profile.container.ProfileContainer; +import org.mvplugins.multiverse.inventories.profile.container.ProfileContainerStoreProvider; +import org.mvplugins.multiverse.inventories.profile.data.PlayerProfile; +import org.mvplugins.multiverse.inventories.profile.key.ContainerType; +import org.mvplugins.multiverse.inventories.profile.key.ProfileType; +import org.mvplugins.multiverse.inventories.profile.key.ProfileTypes; +import org.mvplugins.multiverse.inventories.share.Sharable; +import org.mvplugins.multiverse.inventories.share.Sharables; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Provides methods for asynchronously loading player inventory data. + * This class encapsulates the business logic for fetching inventory, armor, and off-hand contents. + * + * @since 5.2 + */ +@ApiStatus.Experimental +@ApiStatus.AvailableSince("5.2") +@Service +public final class InventoryDataProvider { + + private final ProfileContainerStoreProvider profileContainerStoreProvider; + private final MultiverseInventories inventories; + private final InventoriesConfig inventoriesConfig; + + @Inject + InventoryDataProvider( + @NotNull ProfileContainerStoreProvider profileContainerStoreProvider, + @NotNull MultiverseInventories inventories, + @NotNull InventoriesConfig inventoriesConfig + ) { + this.profileContainerStoreProvider = profileContainerStoreProvider; + this.inventories = inventories; + this.inventoriesConfig = inventoriesConfig; + } + + /** + * Asynchronously loads a player's inventory data. + * If the player is online AND in the specified world, it attempts to get their live inventory. + * Otherwise (offline or online in a different world), it loads from Multiverse-Inventories' stored profiles. + * + * @param targetPlayer The OfflinePlayer whose inventory data to load. + * @param worldName The name of the world to load the inventory from (either live or stored). + * @return A CompletableFuture that will complete with PlayerInventoryData or an exception. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public CompletableFuture loadPlayerInventoryData( + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName + ) { + // If the player is online, prioritize getting their live inventory + if (!targetPlayer.isOnline()) { + return loadInventoryDataFromProfileStorage(targetPlayer, worldName); + } + + Player onlineTarget = targetPlayer.getPlayer(); + // Ensure onlineTarget is not null and their current world matches the requested worldName + if (onlineTarget != null && onlineTarget.getWorld().getName().equalsIgnoreCase(worldName)) { + return loadInventoryDataFromPlayer(onlineTarget, worldName); + } + // If online but in a different world, or getPlayer() returned null, fall through to stored data logic + if (onlineTarget != null) { + Logging.fine("Player " + targetPlayer.getName() + " is online but in world " + onlineTarget.getWorld().getName() + ". Loading stored data for " + worldName + "."); + } else { + Logging.warning("Player " + targetPlayer.getName() + " is online but getPlayer() returned null. Falling back to stored data."); + } + // If the player is offline or online in a different world, or live data failed, load from Multiverse-Inventories' stored profiles + return loadInventoryDataFromProfileStorage(targetPlayer, worldName); + } + + private CompletableFuture loadInventoryDataFromPlayer( + @NotNull Player onlineTarget, + @NotNull String worldName + ) { + // Get the actual ProfileType for the online player + ProfileType profileType = ProfileTypes.forPlayer(onlineTarget); + // Return immediately with live data + return CompletableFuture.completedFuture(new PlayerInventoryData( + onlineTarget.getInventory().getContents(), + onlineTarget.getInventory().getArmorContents(), + onlineTarget.getInventory().getItemInOffHand(), + InventoryStatus.LIVE_INVENTORY, + profileType, + onlineTarget.getHealth(), + onlineTarget.getAttribute(Attribute.GENERIC_MAX_HEALTH).getValue(), + onlineTarget.getLevel(), + onlineTarget.getExp(), + onlineTarget.getFoodLevel(), + onlineTarget.getSaturation(), + String.format("%s (%.1f, %.1f, %.1f)", + onlineTarget.getWorld().getName(), + onlineTarget.getLocation().getX(), + onlineTarget.getLocation().getY(), + onlineTarget.getLocation().getZ()) + )); + } + + private CompletableFuture loadInventoryDataFromProfileStorage( + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName + ) { + return CompletableFuture.supplyAsync(() -> { + ProfileContainer container = profileContainerStoreProvider.getStore(ContainerType.WORLD) + .getContainer(worldName); + if (container == null) { + throw new IllegalStateException("Could not load profile container for world: " + worldName); + } + + PlayerProfile tempProfile = loadMVInvPlayerProfile(container, targetPlayer); + if (tempProfile == null) { + throw new IllegalStateException(InventoryStatus.NO_DATA_FOUND.getFormattedMessage(targetPlayer.getName(), worldName)); + } + ProfileType profileTypeToUse = tempProfile.getProfileType(); + try { + ItemStack[] contents = SingleShareReader.of(inventories, targetPlayer, worldName, profileTypeToUse, Sharables.INVENTORY).read().join(); + ItemStack[] armor = SingleShareReader.of(inventories, targetPlayer, worldName, profileTypeToUse, Sharables.ARMOR).read().join(); + ItemStack offHand = SingleShareReader.of(inventories, targetPlayer, worldName, profileTypeToUse, Sharables.OFF_HAND).read().join(); + + // Non-inventory data + Double storedHealth = getSharableValue(Sharables.HEALTH, targetPlayer, worldName, profileTypeToUse); + Double storedMaxHealth = getSharableValue(Sharables.MAX_HEALTH, targetPlayer, worldName, profileTypeToUse); + Integer storedLevel = getSharableValue(Sharables.LEVEL, targetPlayer, worldName, profileTypeToUse); + Float storedExp = getSharableValue(Sharables.EXPERIENCE, targetPlayer, worldName, profileTypeToUse); + Integer storedFoodLevel = getSharableValue(Sharables.FOOD_LEVEL, targetPlayer, worldName, profileTypeToUse); + Float storedSaturationLevel = getSharableValue(Sharables.SATURATION, targetPlayer, worldName, profileTypeToUse); + String storedLastLocation; + + // Check if LAST_LOCATION is enabled in config + if (!inventoriesConfig.getActiveOptionalShares().contains(Sharables.LAST_LOCATION)) { + storedLastLocation = ChatColor.RED + "Disabled in Config"; + } else { // if the location is null or the world is null + Location storedLocationObject = SingleShareReader.of(inventories, targetPlayer, worldName, profileTypeToUse, Sharables.LAST_LOCATION).read().join(); + if (storedLocationObject == null || storedLocationObject.getWorld() == null) { + storedLastLocation = "N/A (No Location Data)"; + } else { // if the location is valid, print the coordinates and the world + storedLastLocation = String.format("%s (%.1f, %.1f, %.1f)", + storedLocationObject.getWorld().getName(), + storedLocationObject.getX(), + storedLocationObject.getY(), + storedLocationObject.getZ()); + } + } + + return new PlayerInventoryData( + contents, + armor, + offHand, + InventoryStatus.STORED_INVENTORY, + profileTypeToUse, + storedHealth, + storedMaxHealth, + storedLevel, + storedExp, + storedFoodLevel, + storedSaturationLevel, + storedLastLocation + ); + } catch (CompletionException e) { + // Unwrap CompletionException to get the actual cause + throw new IllegalStateException("Error loading inventory data: " + e.getCause().getMessage(), e.getCause()); + } + }); + } + + @Nullable + private PlayerProfile loadMVInvPlayerProfile(ProfileContainer container, OfflinePlayer targetPlayer) { + PlayerProfile survivalProfile = container.getPlayerProfileNow(ProfileTypes.SURVIVAL, targetPlayer); + if (survivalProfile != null) { + return survivalProfile; + } + for (ProfileType type : ProfileTypes.getTypes()) { + if (type.equals(ProfileTypes.SURVIVAL)) continue; + PlayerProfile profile = container.getPlayerProfileNow(type, targetPlayer); + if (profile != null) { + return profile; + } + } + return null; + } + + /** + * Helper method to safely read a sharable value, returning a default if disabled or not found. + * @param sharable The Sharable to read. + * @param targetPlayer The OfflinePlayer. + * @param worldName The world name. + * @param profileType The profile type. + * @param The type of the sharable value. + * @return The sharable value or the default value. + */ + private T getSharableValue(@NotNull Sharable sharable, + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull ProfileType profileType) { + try { + T value = SingleShareReader.of(inventories, targetPlayer, worldName, profileType, sharable).read().join(); + return value; + } catch (CompletionException e) { + Logging.warning("Failed to read sharable '" + sharable.getNames()[0] + "' for player " + targetPlayer.getName() + " in world " + worldName + ": " + e.getCause().getMessage()); + return null; + } + } + + /** + * Asynchronously saves a player's inventory data to their Multiverse-Inventories profile. + * If the player is online and in the target world, their live inventory is also updated. + * + * @param targetPlayer The OfflinePlayer whose inventory data to save. + * @param worldName The name of the world the inventory belongs to. + * @param profileType The ProfileType (e.g., SURVIVAL) associated with this inventory data. + * @param newContents The new main inventory contents (slots 0-35). + * @param newArmor The new armor contents (helmet, chestplate, leggings, boots). + * @param newOffHand The new off-hand item. + * @return A CompletableFuture that completes when saving is done, or an exception occurs. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public CompletableFuture savePlayerInventoryData( + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull ProfileType profileType, + @NotNull ItemStack[] newContents, + @NotNull ItemStack[] newArmor, + @Nullable ItemStack newOffHand + ) { + // Save the updated inventory, armor, and off-hand contents asynchronously + CompletableFuture saveFuture = writeInventoryDataToProfile( + targetPlayer, + worldName, + profileType, + newContents, + newArmor, + newOffHand + ); + + return saveFuture.thenRun(() -> { + Logging.info("Inventory for player " + targetPlayer.getName() + " in world " + worldName + " has been modified and saved."); + updateOnlinePlayerInventoryData(targetPlayer, worldName, newContents, newArmor, newOffHand); + }).exceptionally(throwable -> { + Logging.severe("Failed to save inventory for " + targetPlayer.getName() + " in world " + worldName + ": " + throwable.getMessage()); + throwable.printStackTrace(); + return null; + }); + } + + private void updateOnlinePlayerInventoryData( + OfflinePlayer targetPlayer, + String worldName, + @NotNull ItemStack[] newContents, + @NotNull ItemStack[] newArmor, + ItemStack newOffHand + ) { + // If the target player is online, update their live inventory + if (!targetPlayer.isOnline()) { + return; + } + + Player onlinePlayer = targetPlayer.getPlayer(); + if (onlinePlayer == null) { + return; + } + + // Check if the online player is in the world whose inventory was modified + // This is important to avoid overwriting their current inventory if they are in a different world + if (!onlinePlayer.getWorld().getName().equalsIgnoreCase(worldName)) { + Logging.info("Player " + onlinePlayer.getName() + " is online but in a different world (" + onlinePlayer.getWorld().getName() + "), not updating live inventory."); + return; + } + + // Run Bukkit API calls on the main thread + Bukkit.getScheduler().runTask(inventories, () -> { + onlinePlayer.getInventory().setContents(newContents); + onlinePlayer.getInventory().setArmorContents(newArmor); + onlinePlayer.getInventory().setItemInOffHand(newOffHand); + onlinePlayer.updateInventory(); // Ensure client sees changes + Logging.info("Updated live inventory for online player " + onlinePlayer.getName() + " in world " + worldName); + }); + } + + private CompletableFuture writeInventoryDataToProfile( + @NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull ProfileType profileType, + @NotNull ItemStack[] newContents, + @NotNull ItemStack[] newArmor, + @Nullable ItemStack newOffHand + ) { + // Save the updated inventory, armor, and off-hand contents asynchronously + return CompletableFuture.allOf( + SingleShareWriter.of(inventories, targetPlayer, worldName, profileType, Sharables.INVENTORY) + .write(newContents, true) // true to update if player is online + .thenRun(() -> Logging.fine("Saved inventory for " + targetPlayer.getName() + " in " + worldName)), + SingleShareWriter.of(inventories, targetPlayer, worldName, profileType, Sharables.ARMOR) + .write(newArmor, true) + .thenRun(() -> Logging.fine("Saved armor for " + targetPlayer.getName() + " in " + worldName)), + SingleShareWriter.of(inventories, targetPlayer, worldName, profileType, Sharables.OFF_HAND) + .write(newOffHand, true) + .thenRun(() -> Logging.fine("Saved off-hand for " + targetPlayer.getName() + " in " + worldName)) + ); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryGUIHelper.java b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryGUIHelper.java new file mode 100644 index 00000000..756c5a2c --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryGUIHelper.java @@ -0,0 +1,287 @@ +package org.mvplugins.multiverse.inventories.view; + +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.inventories.MultiverseInventories; + +import java.util.List; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Collections; + +/** + * A helper class for creating and validating items within the custom inventory GUIs. + * This centralizes logic for filler items and slot-specific item validation. + * + * @since 5.2 + */ +@ApiStatus.Experimental +@ApiStatus.AvailableSince("5.2") +@Service +public final class InventoryGUIHelper { + + private final NamespacedKey IS_FILLER_KEY; // Key to mark filler items + private final NamespacedKey IS_DISPLAY_ITEM_KEY; // Key to mark stat display items + + @Inject + InventoryGUIHelper(@NotNull MultiverseInventories inventories) { + this.IS_FILLER_KEY = new NamespacedKey(inventories, "is_mvinv_filler"); + this.IS_DISPLAY_ITEM_KEY = new NamespacedKey(inventories, "is_mvinv_display"); + } + + /** + * Creates a generic filler item for GUI slots. + * + * @param material The material of the filler item. + * @param name The display name of the item. + * @param lore The lore text for the item. + * @return The created ItemStack. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public ItemStack createFillerItem(Material material, String name, String lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.GOLD + name); + meta.setLore(Collections.singletonList(ChatColor.GRAY + lore)); + meta.getPersistentDataContainer().set(IS_FILLER_KEY, PersistentDataType.BYTE, (byte) 1); // store 1 for true + item.setItemMeta(meta); + } + return item; + } + + /** + * Checks if a given ItemStack is a filler item created by this helper. + * + * @param item The ItemStack to check. + * @return True if the item is a filler, false otherwise. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public boolean isFillerItem(@NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return false; + } + PersistentDataContainer persistentDataContainer = item.getItemMeta().getPersistentDataContainer(); + return persistentDataContainer.has(IS_FILLER_KEY, PersistentDataType.BYTE) && + persistentDataContainer.getOrDefault(IS_FILLER_KEY, PersistentDataType.BYTE, (byte) 0) == (byte) 1; + } + + /** + * Checks if a given ItemStack is a general display item created by this helper. + * @param item The ItemStack to check. + * @return True if the item is a display item, false otherwise. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public boolean isDisplayItem(@NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return false; + } + return item.getItemMeta().getPersistentDataContainer().has(IS_DISPLAY_ITEM_KEY, PersistentDataType.BYTE) && + item.getItemMeta().getPersistentDataContainer().get(IS_DISPLAY_ITEM_KEY, PersistentDataType.BYTE) == (byte) 1; + } + /** + * Determines if an ItemStack is valid for a given special inventory slot in the custom GUI. + * This method is used for both armor and off-hand slot validation. + * + * @param item The ItemStack to check. + * @param slot The raw slot number (36-40 for armor/off-hand). + * @return True if the item is valid for the slot, false otherwise. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public boolean isValidItemForSlot(@NotNull ItemStack item, int slot) { + if (item.getType() == Material.AIR) { + return true; // Air is always valid (it means the slot is empty) + } + // If it's a filler item, it's considered valid for its own slot context (e.g., when checking if slot is empty) + if (isFillerItem(item)) { + return true; + } + + return switch (slot) { + case 36 -> item.getType().name().endsWith("_HELMET"); + case 37 -> item.getType().name().endsWith("_CHESTPLATE"); + case 38 -> item.getType().name().endsWith("_LEGGINGS"); + case 39 -> item.getType().name().endsWith("_BOOTS"); + + // Off-hand is very permissive in vanilla. Allow any non-air item. + // If you want to restrict this further (e.g., only shields/totems), + // add more specific Material checks here. + case 40 -> true; + + // Padding slot + // Cannot place items in padding slots + case 41, 42, 43, 44 -> false; + + // For non-special slots (main inventory), any item is generally allowed. + default -> true; + }; + } + + /** + * Creates the appropriate filler item for a given special slot in the custom GUI. + * + * @param slot The raw slot number (36-40). + * @return The specific filler ItemStack for that slot. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public ItemStack createFillerItemForSlot(int slot, boolean isModifiable) { + String helmetLore = isModifiable ? "Place Helmet Here" : "No Helmet"; + String chestplateLore = isModifiable ? "Place Chestplate Here" : "No Chestplate"; + String leggingsLore = isModifiable ? "Place Leggings Here" : "No Leggings"; + String bootsLore = isModifiable ? "Place Boots Here" : "No Boots"; + String offHandLore = isModifiable ? "Place Off-Hand Item Here" : "No Off-Hand Item"; + + return switch (slot) { + case 36 -> createFillerItem(Material.GRAY_STAINED_GLASS_PANE, "Helmet Slot", helmetLore); + case 37 -> createFillerItem(Material.GRAY_STAINED_GLASS_PANE, "Chestplate Slot", chestplateLore); + case 38 -> createFillerItem(Material.GRAY_STAINED_GLASS_PANE, "Leggings Slot", leggingsLore); + case 39 -> createFillerItem(Material.GRAY_STAINED_GLASS_PANE, "Boots Slot", bootsLore); + case 40 -> createFillerItem(Material.GRAY_STAINED_GLASS_PANE, "Off-Hand Slot", offHandLore); + case 41, 42, 43, 44 -> createFillerItem(Material.BARRIER, " ", " "); // Padding slots + default -> new ItemStack(Material.AIR); // Should not happen for these slots + }; + } + + /** + * Creates a generic display item for GUI slots (non-interactable, for showing stats). + * @param material The material of the display item. + * @param name The display name of the item. + * @param lore The lore text for the item. + * @return The created ItemStack. + */ + private ItemStack createDisplayItem(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.AQUA + name); + meta.setLore(lore); + meta.getPersistentDataContainer().set(IS_DISPLAY_ITEM_KEY, PersistentDataType.BYTE, (byte) 1); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createHealthDisplayItem(@Nullable Double health, @Nullable Double maxHealth) { + List lore = new ArrayList<>(); + if (health == null) { + lore.add(ChatColor.RED + "N/A (No Data)"); + } else { + DecimalFormat df = new DecimalFormat("0.0"); + lore.add(ChatColor.WHITE + "Current: " + ChatColor.RED + df.format(health) + ChatColor.WHITE + " / " + ChatColor.RED + df.format(maxHealth)); + } + return createDisplayItem(Material.RED_DYE, "Health", lore); + } + + private ItemStack createLevelDisplayItem(@Nullable Integer level, @Nullable Float exp) { + List lore = new ArrayList<>(); + if (level == null) { + lore.add(ChatColor.RED + "N/A (No Data)"); + } else { + lore.add(ChatColor.WHITE + "Level: " + ChatColor.GREEN + level); + lore.add(ChatColor.WHITE + "Progress: " + ChatColor.AQUA + String.format("%.1f%%", exp * 100)); + } + return createDisplayItem(Material.EXPERIENCE_BOTTLE, "Experience", lore); + } + + private ItemStack createFoodDisplayItem(@Nullable Integer foodLevel, @Nullable Float saturation) { + List lore = new ArrayList<>(); + if (foodLevel == null) { + lore.add(ChatColor.RED + "N/A (No Data)"); + } + lore.add(ChatColor.WHITE + "Food: " + ChatColor.GOLD + foodLevel + ChatColor.WHITE + " / " + ChatColor.GOLD + "20"); + lore.add(ChatColor.WHITE + "Saturation: " + ChatColor.LIGHT_PURPLE + String.format("%.1f", saturation)); + return createDisplayItem(Material.COOKED_BEEF, "Food & Saturation", lore); + } + + private ItemStack createLastLocationDisplayItem(@NotNull String locationString) { + List lore = new ArrayList<>(); + lore.add(ChatColor.WHITE + "Last Location:"); + // Split the location string if it's too long, or just add it directly + // Assuming locationString is already formatted like "world (x.x, y.y, z.z)" + lore.add(ChatColor.YELLOW + locationString); + return createDisplayItem(Material.COMPASS, "Last Location", lore); + } + /** + * Helper method to get an item for a slot, returning a filler if the item is null or air. + * @param item The actual ItemStack from player data. Can be null. + * @param slot The slot number. + * @param isModifiable True if the inventory is modifiable. + * @return The actual item or a generated filler item. + */ + private ItemStack getOrFillItem(ItemStack item, int slot, boolean isModifiable) { + if (item == null || item.getType() == Material.AIR) { + return createFillerItemForSlot(slot, isModifiable); + } + return item; + } + + /** + * Populates the given custom inventory GUI with player inventory data and appropriate filler items. + * @param inv The Inventory GUI to populate. + * @param playerInventoryData The data containing player's contents, armor, and off-hand. + * @param isModifiable True if the inventory is modifiable, false for read-only. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public void populateInventoryGUI(@NotNull Inventory inv, + @NotNull PlayerInventoryData playerInventoryData, + boolean isModifiable) { + // Fill main inventory slots (0–35) + if (playerInventoryData.contents != null) { + for (int i = 0; i < Math.min(playerInventoryData.contents.length, 36); i++) { + inv.setItem(i, playerInventoryData.contents[i]); + } + } + // Armor slot mapping for display in the GUI and add fillers if empty + // GUI Slots: 36=Helmet, 37=Chestplate, 38=Leggings, 39=Boots + // Minecraft Internal: armor[3]=Helmet, armor[2]=Chestplate, armor[1]=Leggings, armor[0]=Boots + + // Slot 36: Helmet + inv.setItem(36, getOrFillItem((playerInventoryData.armor != null ? playerInventoryData.armor[3] : null), 36, isModifiable)); + + // Slot 37: Chestplate + inv.setItem(37, getOrFillItem((playerInventoryData.armor != null ? playerInventoryData.armor[2] : null), 37, isModifiable)); + + // Slot 38: Leggings + inv.setItem(38, getOrFillItem((playerInventoryData.armor != null ? playerInventoryData.armor[1] : null), 38, isModifiable)); + + // Slot 39: Boots + inv.setItem(39, getOrFillItem((playerInventoryData.armor != null ? playerInventoryData.armor[0] : null), 39, isModifiable)); + + // Off-hand slot (40) and add filler if empty + inv.setItem(40, getOrFillItem(playerInventoryData.offHand, 40, isModifiable)); + + // These slots are always treated as read-only by the listener. + inv.setItem(41, createHealthDisplayItem(playerInventoryData.health, playerInventoryData.maxHealth)); + inv.setItem(42, createFoodDisplayItem(playerInventoryData.foodLevel, playerInventoryData.saturation)); + inv.setItem(43, createLevelDisplayItem(playerInventoryData.level, playerInventoryData.exp)); + inv.setItem(44, createLastLocationDisplayItem(playerInventoryData.lastLocation)); + + /*// Fill the remaining slots (41-44) with non-interactable filler items + for (int i = 41; i <= 44; i++) { + inv.setItem(i, createFillerItemForSlot(i, isModifiable)); + */ + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryStatus.java b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryStatus.java new file mode 100644 index 00000000..69bcef36 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/InventoryStatus.java @@ -0,0 +1,62 @@ +package org.mvplugins.multiverse.inventories.view; + +import org.bukkit.ChatColor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Represents the status of an inventory data load operation. + * Provides a fixed set of states for clarity and type safety. + * + * @since 5.2 + */ +@ApiStatus.Experimental +@ApiStatus.AvailableSince("5.2") +public enum InventoryStatus { + /** + * Indicates that live inventory data from an online player was displayed. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + LIVE_INVENTORY(ChatColor.GREEN + "Displaying LIVE inventory"), + + /** + * Indicates that stored inventory data from Multiverse-Inventories profiles was displayed. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + STORED_INVENTORY(ChatColor.GREEN + "Displaying STORED inventory"), + + /** + * Indicates that no player data was found for the specified world/player. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + NO_DATA_FOUND(ChatColor.RED + "No player data found"); + + private final String message; + + InventoryStatus(@NotNull String message) { + this.message = message; + } + + /** + * Gets the full status message including player and world context. + * + * @param playerName The name of the target player. + * @param worldName The name of the target world. + * @return The formatted status message. + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public @NotNull String getFormattedMessage(@NotNull String playerName, @NotNull String worldName) { + if (this == NO_DATA_FOUND) { + return this.message + " for " + playerName + " in world" + worldName + ". Try checking a different world or ensure the player has played in this world."; + } + return this.message + " for " + playerName + " in world " + worldName; + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/ModifiableInventoryHolder.java b/src/main/java/org/mvplugins/multiverse/inventories/view/ModifiableInventoryHolder.java new file mode 100644 index 00000000..2fb80fdd --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/ModifiableInventoryHolder.java @@ -0,0 +1,83 @@ +package org.mvplugins.multiverse.inventories.view; + +import org.bukkit.OfflinePlayer; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.inventories.MultiverseInventories; +import org.mvplugins.multiverse.inventories.profile.key.ProfileType; + +/** + * A custom InventoryHolder that serves as a marker for modifiable inventories. + * It stores the necessary context (player, world, profile type, plugin instance) + * to save changes back to the player's profile when the inventory is closed. + * + * @since 5.2 + */ +@ApiStatus.AvailableSince("5.2") +public final class ModifiableInventoryHolder implements InventoryHolder { + private final OfflinePlayer targetPlayer; + private final String worldName; + private final ProfileType profileType; + + /** + * + * @param targetPlayer + * @param worldName + * @param profileType + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public ModifiableInventoryHolder(@NotNull OfflinePlayer targetPlayer, + @NotNull String worldName, + @NotNull ProfileType profileType) { + this.targetPlayer = targetPlayer; + this.worldName = worldName; + this.profileType = profileType; + } + + /** + * + * @return + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public @NotNull OfflinePlayer getTargetPlayer() { + return targetPlayer; + } + + /** + * + * @return + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public @NotNull String getWorldName() { + return worldName; + } + + /** + * + * @return + * + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public @NotNull ProfileType getProfileType() { + return profileType; + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Inventory getInventory() { + // This method is required by the interface but is not directly used for the marker purpose. + // Throwing UnsupportedOperationException clearly indicates it's not meant to be called. + throw new UnsupportedOperationException("ModifiableInventoryHolder does not provide an Inventory directly."); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/PlayerInventoryData.java b/src/main/java/org/mvplugins/multiverse/inventories/view/PlayerInventoryData.java new file mode 100644 index 00000000..f49c3832 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/PlayerInventoryData.java @@ -0,0 +1,65 @@ +package org.mvplugins.multiverse.inventories.view; + +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.mvplugins.multiverse.inventories.profile.key.ProfileType; + +/** + * Represents the loaded inventory data. + * + * @since 5.2 + */ +@ApiStatus.Experimental +@ApiStatus.AvailableSince("5.2") +public final class PlayerInventoryData { + public final ItemStack[] contents; + public final ItemStack[] armor; + public final ItemStack offHand; + public final InventoryStatus status; + public final ProfileType profileTypeUsed; + + // Non-inventory data + public final Double health; + public final Double maxHealth; + public final Integer level; + public final Float exp; + public final Integer foodLevel; + public final Float saturation; + public final String lastLocation; + + /** + * + * @param contents The player's main inventory contents (slots 0-35). + * @param armor The player's armor contents (boots, leggings, chestplate, helmet). + * @param offHand The player's off-hand item. + * @param status The status of the inventory data load (e.g., LIVE, STORED, NO_DATA_FOUND). + * @param profileTypeUsed The profile type that was used to retrieve the data (e.g., SURVIVAL). + * @param health The player's current health. + * @param maxHealth The player's maximum health. + * @param level The player's current experience level. + * @param exp The player's current experience progress towards the next level (0.0-1.0). + * @param foodLevel The player's current food level (0-20). + * @param saturation The player's current saturation level. + * @param lastLocation The player's last location. + * @since 5.2 + */ + @ApiStatus.AvailableSince("5.2") + public PlayerInventoryData(ItemStack[] contents, ItemStack[] armor, ItemStack offHand, InventoryStatus status, + ProfileType profileTypeUsed, Double health, Double maxHealth, Integer level, Float exp, Integer foodLevel, + Float saturation, String lastLocation) { + this.contents = contents; + this.armor = armor; + this.offHand = offHand; + this.status = status; + this.profileTypeUsed = profileTypeUsed; + + // Non-inventory data + this.health = health; + this.maxHealth = maxHealth; + this.level = level; + this.exp = exp; + this.foodLevel = foodLevel; + this.saturation = saturation; + this.lastLocation = lastLocation; + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/view/ReadOnlyInventoryHolder.java b/src/main/java/org/mvplugins/multiverse/inventories/view/ReadOnlyInventoryHolder.java new file mode 100644 index 00000000..9828440c --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/view/ReadOnlyInventoryHolder.java @@ -0,0 +1,25 @@ +package org.mvplugins.multiverse.inventories.view; // New package + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A custom InventoryHolder that serves as a marker for read-only inventories. + * Inventories created with this holder will have their interactions cancelled by the InventoryViewListener. + * + * @since 5.2 + */ +@ApiStatus.AvailableSince("5.2") +public final class ReadOnlyInventoryHolder implements InventoryHolder { + /** + * {@inheritDoc} + */ + @Override + public @NotNull Inventory getInventory() { + // This method is required by the interface but is not directly used for the marker purpose. + // Throwing UnsupportedOperationException clearly indicates it's not meant to be called. + throw new UnsupportedOperationException("ReadOnlyInventoryHolder does not provide an Inventory directly."); + } +} diff --git a/src/main/resources/multiverse-inventories_zh.properties b/src/main/resources/multiverse-inventories_zh.properties new file mode 100644 index 00000000..cf452f59 --- /dev/null +++ b/src/main/resources/multiverse-inventories_zh.properties @@ -0,0 +1,103 @@ +mv-inventories.test.string=资源文件中的一个测试字符串 + +# Generic Strings +mv-inventories.generic.sorry=抱歉…… +mv-inventories.generic.page=页面 +mv-inventories.generic.of=of +mv-inventories.generic.unloaded=卸载 +mv-inventories.generic.plugindisabled=插件已被禁用! +mv-inventories.generic.error=[错误] +mv-inventories.generic.success=[成功] +mv-inventories.generic.info=[信息] +mv-inventories.generic.help=[帮助] +mv-inventories.generic.commandnopermission=你没有权限执行 {command}。 ({permission}) +mv-inventories.generic.theconsole=控制台 +mv-inventories.generic.notloggedin={player} 目前处于离线状态! +mv-inventories.generic.off=关闭 + +# Errors +mv-inventories.error.configload=加载配置文件时遇到错误。正在禁用…… +mv-inventories.error.dataload=加载数据文件时遇到错误。正在禁用…… +mv-inventories.error.nogroup=&6没有名为 &f{group}&6 的组 +mv-inventories.error.noworld=&6没有名为 &f{world}&6 的世界 +mv-inventories.error.noworldprofile=&6世界 &f{world}&6 没有配置文件 +mv-inventories.error.nosharesspecified=&c你没有指定任何有效的共享! + +# Conflicts +mv-inventories.conflict.results=在以下组中检测到冲突: ''{group1}'' 和 ''{group2}'',因为它们在世界: ''{worlds}'' 中都共享了: ''{shares}'' +mv-inventories.conflict.checking=正在检查组中的冲突…… +mv-inventories.conflict.found=已经发现冲突……如果这些冲突没有解决,你的数据可能会遇到问题。 +mv-inventories.conflict.notfound=没有发现组冲突! + +# Commands +## Info Command +mv-inventories.info.world=&b===[ 世界信息: &6{world}&b ]=== +mv-inventories.info.world.info=&6组:&f {groups} +mv-inventories.info.group=&b===[ 组信息: &6{group}&b ]=== +mv-inventories.info.group.info=&6世界:&f {worlds} +mv-inventories.info.group.infoshares=&b共享:&f {shares} +mv-inventories.info.group.infonegativeshares=&b禁用的共享:&f {shares} +mv-inventories.info.zeroarg=你只能在游戏中使用该命令的无参数版本! + +# List Command +mv-inventories.list.groups=&b===[ 组列表 ]=== +mv-inventories.list.groups.info=&6组:&f {groups} + +# Reload Command +mv-inventories.reload.complete=&b===[ 重新加载完成! ]=== + +# Addworld Command +mv-inventories.addworld.worldadded=&6世界:&f {world} &6已被添加到组: &f{group} +mv-inventories.addworld.worldalreadyexists=&6世界:&f {world} &6已在以下组中: &f{group} + +# Removeworld Command +mv-inventories.removeworld.worldremoved=&6世界: &f {world} &6已从以下组中移除: &f{group} +mv-inventories.removeworld.worldnotingroup=&6世界: &f {world} &6不在以下组中: &f{group} + +# Add/remove shares command +mv-inventories.shares.nowsharing=&6组: &f{group} &6正在共享: &f{shares} &6禁用的共享: &f{negativeshares} + +# Add/remove disabled shares command +mv-inventories.disabledshares.nowsharing=&6组 &f{group} &6的以下共享已禁用: &f{shares} + +# Spawn command +mv-inventories.spawn.teleporting=正在传送到当前世界组的出生点…… +mv-inventories.spawn.teleportedby=你已被玩家 {player} 传送。 +mv-inventories.spawn.teleportconsoleerror=从控制台运行该指令时,你必须指定玩家! + +# Debug command +mv-inventories.debug.invaliddebug=&f无效的调试等级。请使用数字 0-3。 &b(3 会出现很多很多信息!) +mv-inventories.debug.set=调试模式已设置为 {mode}。 + +# Toggle command +mv-inventories.toggle.nowusingoptional=&6当玩家的世界改变时,&f{share} &6将会被考虑。 +mv-inventories.toggle.nownotusingoptional=&6当玩家的世界改变时,&f{share} &6将不在被考虑。 +mv-inventories.toggle.nooptionalshares=&f{share} &6 不是一个可选择的共享! + +# Group command +mv-inventories.group.commandprompt=&6你想做什么? &f创建(Create)&6, &f编辑(Edit) &6或者 &f删除(Delete)&6。 输入 &f##&6 取消当前操作。 +mv-inventories.group.createprompt=&6请输入新建组的名称: +mv-inventories.group.editprompt=&6想要编辑哪个组? {groups} +mv-inventories.group.deleteprompt=&6想要删除哪个组? {groups} +mv-inventories.group.modifyprompt=&6你想要修改 &e{group}&6 的哪个属性? &f世界(Worlds) &6或者 &f共享(Shares)&6。 输入 &f##&6 结束。 +mv-inventories.group.worldsprompt=&6输入你想要添加到组 &f{group}&6 的世界 或者输入 &f@&6 继续。 如果需要移除世界,在名称前加减号。(例如: &f-worldname&6)。 当前世界: {worlds} +mv-inventories.group.sharesprompt=&6输入全部(&fall&6)或者一个指定的共享加入到组 &f{group}&6,或者输入 &f@&6 继续。如果需要移除共享,在名称前加减号。(例如: &f-inventory&6). 当前的共享:{shares} +mv-inventories.group.invalidname=&c名称无效! 只能包含字母、数字和下划线。 +mv-inventories.group.exists=&c组已经存在!(&f{group}&c) +mv-inventories.group.removed=&2已移除组: &f{group} +mv-inventories.group.worldsempty=&c您可能没有不包含任何世界的组,请添加世界或输入&f##&c取消。 +mv-inventories.group.creationcomplete=&2你创建了新的组 '{group}'! +mv-inventories.group.updated=&2组已经更新! +mv-inventories.group.nonconversable=无法访问会话(远程控制台?) +mv-inventories.group.invalidoption=&c这不是一个有效的选项!输入&f##&c停止处理组。 + +# Migrate command +mv-inventories.migrate.pluginnotenabled=&f{plugin} &6未启用,因此你可能无法从中导入数据! +mv-inventories.migrate.unsupportedplugin=&6抱歉, ''&f{plugin}&6'' 不支持导入。 +mv-inventories.migrate.confirmprompt=&6你确认从 &f{plugin}&6 导入数据吗?这将覆盖 Multiverse-Inventories 中已存在的玩家数据!!! +mv-inventories.migrate.success=&2成功从 &f{plugin} &2中导入数据! +mv-inventories.migrate.failed=&c从 &f{plugin} &c中导入数据失败! + +# Deletegroup command +mv-inventories.deletegroup.confirmprompt=您确定要删除组 &f{group}&6 吗? +mv-inventories.deletegroup.success=&2成功删除组: &f{group}! \ No newline at end of file diff --git a/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt b/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt index 53dad107..dd0add30 100644 --- a/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt +++ b/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt @@ -15,7 +15,7 @@ class InjectionTest : TestWithMockBukkit() { @Test fun `InventoriesCommand are available as a service`() { - assertEquals(28, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) + assertEquals(30, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) } @Test @@ -25,7 +25,7 @@ class InjectionTest : TestWithMockBukkit() { @Test fun `InventoriesListener is available as a service`() { - assertEquals(4, serviceLocator.getAllActiveServices(MVInvListener::class.java).size) + assertEquals(5, serviceLocator.getAllActiveServices(MVInvListener::class.java).size) } @Test