diff --git a/pom.xml b/pom.xml index a761ebb..5cb84a8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.yourname SellPlugin - 1.0-SNAPSHOT + 1.2-SNAPSHOT jar @@ -45,6 +45,13 @@ provided + + net.dmulloy2 + ProtocolLib + 5.4.0 + provided + + su.nightexpress.nightcore NightCore diff --git a/src/main/java/com/yourname/sellplugin/SellPlugin.java b/src/main/java/com/yourname/sellplugin/SellPlugin.java index 3827c4d..94a7a49 100644 --- a/src/main/java/com/yourname/sellplugin/SellPlugin.java +++ b/src/main/java/com/yourname/sellplugin/SellPlugin.java @@ -1,11 +1,22 @@ package com.yourname.sellplugin; +import com.yourname.sellplugin.command.SellAllCommand; import com.yourname.sellplugin.command.SellCommand; +import com.yourname.sellplugin.command.SellMultiCommand; +import com.yourname.sellplugin.command.FastSellAllCommand; +import com.yourname.sellplugin.command.ShowWorthCommand; +import com.yourname.sellplugin.command.TopSellCommand; +import com.yourname.sellplugin.command.WorthCommand; import com.yourname.sellplugin.economy.EconomyManager; import com.yourname.sellplugin.gui.GUIListener; +import com.yourname.sellplugin.listener.WorthPacketListener; +import com.yourname.sellplugin.listener.WorthRefreshListener; import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.manager.ConfigMigrator; import com.yourname.sellplugin.manager.MultiplierManager; import com.yourname.sellplugin.manager.PriceManager; +import com.yourname.sellplugin.manager.SellManager; +import com.yourname.sellplugin.manager.WorthVisibilityManager; import org.bukkit.plugin.java.JavaPlugin; public class SellPlugin extends JavaPlugin { @@ -14,18 +25,22 @@ public class SellPlugin extends JavaPlugin { private ConfigManager configManager; private PriceManager priceManager; private MultiplierManager multiplierManager; + private SellManager sellManager; + private WorthVisibilityManager worthVisibilityManager; + private WorthPacketListener worthPacketListener; @Override public void onEnable() { - // Initialize Config saveDefaultConfig(); + new ConfigMigrator(this).migrate(); configManager = new ConfigManager(this); - // Initialize Managers priceManager = new PriceManager(this); priceManager.loadPrices(); - + multiplierManager = new MultiplierManager(this); + sellManager = new SellManager(this); + worthVisibilityManager = new WorthVisibilityManager(this); economyManager = new EconomyManager(this); if (!economyManager.setupEconomy()) { @@ -34,9 +49,20 @@ public void onEnable() { return; } - // Register Commands & Events getCommand("sell").setExecutor(new SellCommand(this)); + getCommand("sellall").setExecutor(new SellAllCommand(this)); + getCommand("fastsellall").setExecutor(new FastSellAllCommand(this)); + getCommand("topsell").setExecutor(new TopSellCommand(this)); + getCommand("sellmulti").setExecutor(new SellMultiCommand(this)); + getCommand("sellworth").setExecutor(new WorthCommand(this)); + ShowWorthCommand showWorthCommand = new ShowWorthCommand(this); + getCommand("showworth").setExecutor(showWorthCommand); + getCommand("showworth").setTabCompleter(showWorthCommand); getServer().getPluginManager().registerEvents(new GUIListener(this), this); + getServer().getPluginManager().registerEvents(new WorthRefreshListener(this), this); + + worthPacketListener = new WorthPacketListener(this); + worthPacketListener.register(); getLogger().info("SellPlugin has been enabled successfully."); } @@ -46,11 +72,19 @@ public void onDisable() { if (multiplierManager != null) { multiplierManager.saveAll(); } + if (worthVisibilityManager != null) { + worthVisibilityManager.saveNow(); + } + if (worthPacketListener != null) { + worthPacketListener.unregister(); + } getLogger().info("SellPlugin has been disabled."); } public EconomyManager getEconomyManager() { return economyManager; } - public ConfigManager getConfigManager() { return configManager; } - public PriceManager getPriceManager() { return priceManager; } + public ConfigManager getConfigManager() { return configManager; } + public PriceManager getPriceManager() { return priceManager; } public MultiplierManager getMultiplierManager() { return multiplierManager; } + public SellManager getSellManager() { return sellManager; } + public WorthVisibilityManager getWorthVisibilityManager() { return worthVisibilityManager; } } diff --git a/src/main/java/com/yourname/sellplugin/command/FastSellAllCommand.java b/src/main/java/com/yourname/sellplugin/command/FastSellAllCommand.java new file mode 100644 index 0000000..fe376a7 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/FastSellAllCommand.java @@ -0,0 +1,32 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class FastSellAllCommand implements CommandExecutor { + + private final SellPlugin plugin; + + public FastSellAllCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!player.hasPermission("sellplugin.use")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + plugin.getSellManager().sellAll(player); + return true; + } +} diff --git a/src/main/java/com/yourname/sellplugin/command/SellAllCommand.java b/src/main/java/com/yourname/sellplugin/command/SellAllCommand.java new file mode 100644 index 0000000..1f1da7e --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/SellAllCommand.java @@ -0,0 +1,32 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.gui.SellAllGUI; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class SellAllCommand implements CommandExecutor { + private final SellPlugin plugin; + + public SellAllCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!player.hasPermission("sellplugin.use")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + new SellAllGUI(plugin, player).open(player); + return true; + } +} diff --git a/src/main/java/com/yourname/sellplugin/command/SellCommand.java b/src/main/java/com/yourname/sellplugin/command/SellCommand.java index 4538db3..cd573ed 100644 --- a/src/main/java/com/yourname/sellplugin/command/SellCommand.java +++ b/src/main/java/com/yourname/sellplugin/command/SellCommand.java @@ -1,7 +1,7 @@ package com.yourname.sellplugin.command; import com.yourname.sellplugin.SellPlugin; -import com.yourname.sellplugin.gui.SellGUI; +import com.yourname.sellplugin.gui.ShopMainGUI; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -16,21 +16,28 @@ public SellCommand(SellPlugin plugin) { @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (!(sender instanceof Player)) { - sender.sendMessage("Only players can use this command."); + // Handle /sell reload + if (args.length > 0 && args[0].equalsIgnoreCase("reload")) { + if (!sender.hasPermission("sellplugin.reload")) { + sender.sendMessage(plugin.getConfigManager().getText("reload-no-permission", "&cYou do not have permission to reload the config.")); + return true; + } + plugin.getConfigManager().reload(); + sender.sendMessage(plugin.getConfigManager().getText("reload-success", "&aSellPlugin configuration reloaded.")); return true; } - Player player = (Player) sender; + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } if (!player.hasPermission("sellplugin.use")) { player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); return true; } - SellGUI gui = new SellGUI(plugin, player); - gui.open(player); - + new ShopMainGUI(plugin, player).open(player); return true; } } diff --git a/src/main/java/com/yourname/sellplugin/command/SellMultiCommand.java b/src/main/java/com/yourname/sellplugin/command/SellMultiCommand.java new file mode 100644 index 0000000..7c43803 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/SellMultiCommand.java @@ -0,0 +1,37 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.gui.SellMultiGUI; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class SellMultiCommand implements CommandExecutor { + private final SellPlugin plugin; + + public SellMultiCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!plugin.getConfigManager().isProgressGuiEnabled()) { + player.sendMessage(plugin.getConfigManager().getText("feature-disabled", "&cThis feature is currently disabled.")); + return true; + } + + if (!player.hasPermission("sellplugin.use")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + new SellMultiGUI(plugin, player).open(player); + return true; + } +} diff --git a/src/main/java/com/yourname/sellplugin/command/ShowWorthCommand.java b/src/main/java/com/yourname/sellplugin/command/ShowWorthCommand.java new file mode 100644 index 0000000..b6aa8de --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/ShowWorthCommand.java @@ -0,0 +1,88 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.util.Scheduler; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * {@code /showworth [true|false]} — lets a player show or hide the worth + * tooltip for themselves. With no argument it toggles the current state. + */ +public class ShowWorthCommand implements CommandExecutor, TabCompleter { + + private final SellPlugin plugin; + + public ShowWorthCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText( + "player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!player.hasPermission("sellplugin.use")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + boolean nowVisible; + if (args.length == 0) { + nowVisible = plugin.getWorthVisibilityManager().toggle(player.getUniqueId()); + } else { + Boolean parsed = parseBoolean(args[0]); + if (parsed == null) { + player.sendMessage(plugin.getConfigManager().getText( + "showworth-usage", "&cUsage: /showworth [true|false]")); + return true; + } + nowVisible = parsed; + plugin.getWorthVisibilityManager().setVisible(player.getUniqueId(), nowVisible); + } + + String key = nowVisible ? "showworth-enabled" : "showworth-disabled"; + String def = nowVisible + ? "&aItem worth is now &lshown&r&a in your inventory." + : "&eItem worth is now &lhidden&r&e in your inventory."; + player.sendMessage(plugin.getConfigManager().getText(key, def)); + + // Resend the inventory so the change is reflected immediately. Runs on + // the player's own region thread for Folia compatibility. + Scheduler.runEntityLater(plugin, player, () -> { + if (player.isOnline()) { + player.updateInventory(); + } + }, 1L); + return true; + } + + private Boolean parseBoolean(String arg) { + String a = arg.toLowerCase(); + return switch (a) { + case "true", "on", "show", "yes", "enable", "enabled" -> Boolean.TRUE; + case "false", "off", "hide", "no", "disable", "disabled" -> Boolean.FALSE; + default -> null; + }; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + return Stream.of("true", "false") + .filter(s -> s.startsWith(args[0].toLowerCase())) + .collect(Collectors.toList()); + } + return List.of(); + } +} diff --git a/src/main/java/com/yourname/sellplugin/command/TopSellCommand.java b/src/main/java/com/yourname/sellplugin/command/TopSellCommand.java new file mode 100644 index 0000000..31e6772 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/TopSellCommand.java @@ -0,0 +1,38 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.gui.TopSellGUI; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class TopSellCommand implements CommandExecutor { + + private final SellPlugin plugin; + + public TopSellCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!plugin.getConfigManager().isTopSellEnabled()) { + player.sendMessage(plugin.getConfigManager().getText("feature-disabled", "&cThis feature is currently disabled.")); + return true; + } + + if (!player.hasPermission("sellplugin.topsell")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + new TopSellGUI(plugin, player, 0).open(player); + return true; + } +} diff --git a/src/main/java/com/yourname/sellplugin/command/WorthCommand.java b/src/main/java/com/yourname/sellplugin/command/WorthCommand.java new file mode 100644 index 0000000..0c4d521 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/command/WorthCommand.java @@ -0,0 +1,32 @@ +package com.yourname.sellplugin.command; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.gui.WorthGUI; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class WorthCommand implements CommandExecutor { + private final SellPlugin plugin; + + public WorthCommand(SellPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getConfigManager().getText("player-only-command", "&cOnly players can use this command.")); + return true; + } + + if (!player.hasPermission("sellplugin.use")) { + player.sendMessage(plugin.getConfigManager().getMessage("no-permission")); + return true; + } + + new WorthGUI(plugin, player, "all", 0).open(player); + return true; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/CategoryItemsGUI.java b/src/main/java/com/yourname/sellplugin/gui/CategoryItemsGUI.java new file mode 100644 index 0000000..b7f7103 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/CategoryItemsGUI.java @@ -0,0 +1,282 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.manager.PriceManager; +import com.yourname.sellplugin.util.ItemNameFormatter; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionData; +import org.bukkit.potion.PotionType; + +import java.util.*; + +/** + * Paginated item-list GUI – 9×6 (54 slots). + * + * Rows 1-5 (slots 0-44): item display area (up to 45 items per page). + * Row 6 (slots 45-53): navigation bar. + * 45 – Back (return to CategoryProgressGUI) + * 48 – Previous page (directly left of page indicator) + * 49 – Page indicator (paper) + * 50 – Next page (directly right of page indicator) + * 53 – Sell All in category + */ +public class CategoryItemsGUI implements InventoryHolder { + + private static final int ITEMS_PER_PAGE = 45; + + // Navigation slots + public static final int SLOT_BACK = 45; + public static final int SLOT_PREV = 48; + public static final int SLOT_INFO = 49; + public static final int SLOT_NEXT = 50; + public static final int SLOT_SELL_ALL = 53; + + private final Inventory inv; + private final SellPlugin plugin; + private final Player player; + private final String categoryId; + + /** Ordered list of all item keys in this category that have a price. */ + private final List itemKeys; + private int page; // 0-based + + public CategoryItemsGUI(SellPlugin plugin, Player player, String categoryId, int page) { + this.plugin = plugin; + this.player = player; + this.categoryId = categoryId; + this.page = page; + this.itemKeys = buildItemKeyList(); + + ConfigManager cfg = plugin.getConfigManager(); + String title = cfg.getCategoryDisplayName(categoryId) + + cfg.getText("category-items.title-suffix", "&8 – Items"); + this.inv = Bukkit.createInventory(this, 54, title); + populate(); + } + + // ── Build the sorted list of all item keys in this category ───────────── + + private List buildItemKeyList() { + PriceManager pm = plugin.getPriceManager(); + List keys = new ArrayList<>(); + for (String key : pm.getAllItemKeys()) { + if (categoryId.equalsIgnoreCase(pm.getCategory(key))) { + keys.add(key); + } + } + Collections.sort(keys); + return keys; + } + + // ── Populate inventory ─────────────────────────────────────────────────── + + private void populate() { + inv.clear(); + ConfigManager cfg = plugin.getConfigManager(); + + // Background for navigation row + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 45; i < 54; i++) inv.setItem(i, bg); + + // Items area + int start = page * ITEMS_PER_PAGE; + int end = Math.min(start + ITEMS_PER_PAGE, itemKeys.size()); + for (int i = start; i < end; i++) { + inv.setItem(i - start, buildItemDisplay(itemKeys.get(i))); + } + // Fill remaining item area with gray glass + ItemStack filler = makeItem(Material.GRAY_STAINED_GLASS_PANE, " ", Collections.emptyList()); + for (int i = (end - start); i < 45; i++) inv.setItem(i, filler); + + // ── Back button ──────────────────────────────────────────────────── + List backLore = cfg.getIconLore("back", + Collections.singletonList(ChatColor.GRAY + "Return to category view.")); + inv.setItem(SLOT_BACK, makeItem( + cfg.getIconMaterial("back", Material.ARROW), + cfg.getIconName("back", "&c&lBack"), + backLore)); + + // ── Previous page ────────────────────────────────────────────────── + if (page > 0) { + List prevLore = cfg.getIconLore("prev-page", + Collections.singletonList(ChatColor.GRAY + "Previous page.")); + inv.setItem(SLOT_PREV, makeItem( + cfg.getIconMaterial("prev-page", Material.ARROW), + cfg.getIconName("prev-page", "&e← Previous"), + prevLore)); + } + + // ── Page indicator ───────────────────────────────────────────────── + int totalPages = Math.max(1, (int) Math.ceil((double) itemKeys.size() / ITEMS_PER_PAGE)); + List infoLore = Collections.singletonList( + cfg.getText("category-items.total-items", "&7Total items: {count}") + .replace("{count}", String.valueOf(itemKeys.size()))); + inv.setItem(SLOT_INFO, makeItem( + cfg.getIconMaterial("page-indicator", Material.PAPER), + cfg.getText("category-items.page-indicator", "&fPage {page} / {total}") + .replace("{page}", String.valueOf(page + 1)) + .replace("{total}", String.valueOf(totalPages)), + infoLore)); + + // ── Next page ────────────────────────────────────────────────────── + if ((page + 1) * ITEMS_PER_PAGE < itemKeys.size()) { + List nextLore = cfg.getIconLore("next-page", + Collections.singletonList(ChatColor.GRAY + "Next page.")); + inv.setItem(SLOT_NEXT, makeItem( + cfg.getIconMaterial("next-page", Material.ARROW), + cfg.getIconName("next-page", "&eNext →"), + nextLore)); + } + + // ── Sell-All button ──────────────────────────────────────────────── + double catValue = plugin.getSellManager().calculateCategoryValue(player, categoryId); + int catCount = plugin.getSellManager().countCategoryItems(player, categoryId); + List sellLore = new ArrayList<>(); + sellLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-items.category-label", "category: ")) + + ChatColor.WHITE + ChatColor.stripColor(cfg.getCategoryDisplayName(categoryId))); + if (catCount > 0) { + sellLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-items.items-label", "items: ")) + + ChatColor.WHITE + NumberFormatter.format(catCount)); + sellLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-items.earn-label", "earn: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(catValue)); + } else { + sellLore.add(ChatColor.RED + " ▸ " + SmallCaps.convert(cfg.getText("category-items.no-items-to-sell", "no items to sell."))); + } + inv.setItem(SLOT_SELL_ALL, makeItem( + cfg.getIconMaterial("sell-category", Material.GOLD_INGOT), + cfg.getIconName("sell-category", "&a&lSell Category"), + sellLore)); + } + + // ── Build a display ItemStack for a price-list entry ───────────────────── + + private ItemStack buildItemDisplay(String itemKey) { + PriceManager pm = plugin.getPriceManager(); + double base = pm.getPrice(itemKey); + String itemCategory = pm.getCategory(itemKey); + double effectiveMultiplier = plugin.getMultiplierManager().getEffectiveMultiplier(player, itemCategory); + double effective = base * effectiveMultiplier; + + // Build correct ItemStack (handles potions with PotionMeta) + ItemStack item = resolveItemStack(itemKey); + + List lore = new ArrayList<>(); + lore.add(ChatColor.DARK_GRAY + "━━━━━━━━━━━━━━━━━━━━━"); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Base: " + + ChatColor.GREEN + "$" + NumberFormatter.format(base)); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Mult: " + + ChatColor.AQUA + String.format("%.2fx", effectiveMultiplier)); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Price: " + + ChatColor.GREEN + "$" + NumberFormatter.format(effective)); + lore.add(ChatColor.DARK_GRAY + "━━━━━━━━━━━━━━━━━━━━━"); + + String displayName = ChatColor.WHITE + ItemNameFormatter.formatKey(itemKey); + + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(displayName); + meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + /** + * Creates an ItemStack for the given item key. + * For potion keys (e.g. "POTION:NIGHT_VISION") the correct PotionMeta + * is applied so the correct potion colour is shown in the GUI. + */ + private ItemStack resolveItemStack(String itemKey) { + if (itemKey.contains(":")) { + String[] parts = itemKey.split(":", 2); + Material mat = Material.matchMaterial(parts[0]); + if (mat == null || mat.isAir() || !mat.isItem()) return new ItemStack(Material.BARRIER); + + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta instanceof PotionMeta potionMeta) { + try { + PotionType type = PotionType.valueOf(parts[1]); + potionMeta.setBasePotionData(new PotionData(type)); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) { + // Unknown potion type – leave meta as-is + } + } + return item; + } + Material mat = Material.matchMaterial(itemKey); + if (mat == null || mat.isAir() || !mat.isItem()) return new ItemStack(Material.BARRIER); + return new ItemStack(mat); + } + + // ── Item clicked ───────────────────────────────────────────────────────── + + /** + * Returns the item key at the given slot (0-44), or null if none. + */ + public String getItemKeyAtSlot(int slot) { + if (slot < 0 || slot >= 45) return null; + int idx = page * ITEMS_PER_PAGE + slot; + if (idx < itemKeys.size()) return itemKeys.get(idx); + return null; + } + + // ── Navigation ─────────────────────────────────────────────────────────── + + public boolean hasPrevPage() { return page > 0; } + + public boolean hasNextPage() { + return (page + 1) * ITEMS_PER_PAGE < itemKeys.size(); + } + + public CategoryItemsGUI prevPage() { + return new CategoryItemsGUI(plugin, player, categoryId, page - 1); + } + + public CategoryItemsGUI nextPage() { + return new CategoryItemsGUI(plugin, player, categoryId, page + 1); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public String getCategoryId() { + return categoryId; + } + + public int getPage() { + return page; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/CategoryProgressGUI.java b/src/main/java/com/yourname/sellplugin/gui/CategoryProgressGUI.java new file mode 100644 index 0000000..31c1d6b --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/CategoryProgressGUI.java @@ -0,0 +1,241 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Category progress GUI – full double-chest (54 slots). + * + * A vertical "snake / U-Path" of multiplier milestones winds through the menu. + * Each milestone goes from 1.0x to 3.0x in 0.1 increments (21 nodes). + * + * The snake starts vertically (column 1 going down, then column 2 going up, …). + * + * Colour key (configurable via config.yml progress-bar section): + * GREEN – completed milestone + * YELLOW – current / in-progress milestone (shows money earned & required) + * GRAY – locked / future milestone + * + * The very first path node opens the CategoryItemsGUI. + * Back button sits at slot 53 (bottom-right). + */ +public class CategoryProgressGUI implements InventoryHolder { + + // ── Constants ──────────────────────────────────────────────────────────── + + private static final int SIZE = 54; + + /** Floating-point tolerance for milestone comparisons. */ + private static final double EPSILON = 0.001; + + /** Back button slot (bottom-right). */ + public static final int SLOT_BACK = 53; + + /** + * W-shape path (21 nodes). + * + * Two connected U-shapes form a W across rows 1-4 (U1) and rows 0-4 (U2). + * Slot layout reference (row × col, 0-indexed): + * Col: 0 1 2 3 4 5 6 7 8 + * Row0: 0 1 2 3 4 5 6 7 8 + * Row1: 9 10 11 12 13 14 15 16 17 + * Row2: 18 19 20 21 22 23 24 25 26 + * Row3: 27 28 29 30 31 32 33 34 35 + * Row4: 36 37 38 39 40 41 42 43 44 + * Row5: 45 46 47 48 49 50 51 52 53 + * + * Visual W (cols 1-8, rows 0-4): + * . . . . . . [7] [8] + * [10] . [12][13][14] . [16] . + * [19] . [21] . [23] . [25] . + * [28] . [30] . [32] . [34] . + * [37][38][39] . [41][42][43] . + * + * U1: ↓ col1 (rows 1-4) → right 2 (row4) → ↑ col3 (rows 4-1) → right 2 (row1) + * U2: ↓ col5 (rows 1-4) → right 2 (row4) → ↑ col7 (rows 4-0) → right 1 (row0) + */ + private static final int[] PATH = { + 10, 19, 28, 37, 38, 39, 30, 21, 12, 13, 14, // U1: down col1, right, up col3, step right + 23, 32, 41, 42, 43, 34, 25, 16, 7, 8 // U2: down col5, right, up col7, step right + }; + + /** Multiplier value for each path node: 1.0, 1.1, 1.2 … 3.0. */ + private static final double[] MILESTONES = new double[PATH.length]; + static { + for (int i = 0; i < MILESTONES.length; i++) { + MILESTONES[i] = 1.0 + i * 0.1; + } + } + + // ── Instance fields ────────────────────────────────────────────────────── + + private final Inventory inv; + private final SellPlugin plugin; + private final Player player; + private final String categoryId; + + public CategoryProgressGUI(SellPlugin plugin, Player player, String categoryId) { + this.plugin = plugin; + this.player = player; + this.categoryId = categoryId; + + ConfigManager cfg = plugin.getConfigManager(); + String title = cfg.getCategoryDisplayName(categoryId); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(); + } + + // ── Layout ─────────────────────────────────────────────────────────────── + + private void populate() { + ConfigManager cfg = plugin.getConfigManager(); + + // Fill everything with configurable filler block + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < SIZE; i++) inv.setItem(i, bg); + + // ── Snake path ────────────────────────────────────────────────────── + double mult = plugin.getMultiplierManager().getMultiplier(player, categoryId); + double moneyEarned = plugin.getMultiplierManager().getMoneyEarned(player, categoryId); + buildSnakePath(mult, moneyEarned); + + // ── Back button (bottom-right) ────────────────────────────────────── + List backLore = cfg.getIconLore("back", + Collections.singletonList(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.back-lore", "return to the main menu.")))); + inv.setItem(SLOT_BACK, + makeItem(cfg.getIconMaterial("back", Material.ARROW), + cfg.getIconName("back", "&c&l" + SmallCaps.convert("back")), + backLore)); + } + + // ── Snake / U-Path builder ─────────────────────────────────────────────── + + private void buildSnakePath(double currentMultiplier, double moneyEarned) { + ConfigManager cfg = plugin.getConfigManager(); + + for (int i = 0; i < PATH.length; i++) { + int slot = PATH[i]; + double milestone = MILESTONES[i]; + + // Determine colour state + boolean completed = currentMultiplier >= milestone + 0.1 - EPSILON; + boolean inProgress = !completed && currentMultiplier >= milestone - EPSILON; + + Material paneMat; + ChatColor nameColour; + String status; + + if (completed) { + paneMat = cfg.getProgressBarCompletedColor(); + nameColour = ChatColor.GREEN; + status = SmallCaps.convert(cfg.getText("category-progress.node-status-completed", "completed")); + } else if (inProgress) { + paneMat = cfg.getProgressBarInProgressColor(); + nameColour = ChatColor.YELLOW; + status = SmallCaps.convert(cfg.getText("category-progress.node-status-in-progress", "in progress")); + } else { + paneMat = cfg.getProgressBarLockedColor(); + nameColour = ChatColor.DARK_GRAY; + status = SmallCaps.convert(cfg.getText("category-progress.node-status-locked", "locked")); + } + + // First node uses the category icon instead of glass + boolean isStart = (i == 0); + Material displayMat = isStart ? cfg.getCategoryMaterial(categoryId) : paneMat; + + String label = nameColour + "" + ChatColor.BOLD + + String.format("%.1fx", milestone) + + " " + SmallCaps.convert(cfg.getText("category-progress.node-multiplier-suffix", "multiplier")); + + String separator = cfg.getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + List lore = new ArrayList<>(); + lore.add(separator); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.node-status-label", "status: ")) + nameColour + status); + + if (isStart) { + lore.add(separator); + lore.add(ChatColor.YELLOW + " ✦ " + SmallCaps.convert(cfg.getText("category-progress.node-click-to-view", "click to view items & prices"))); + } + + if (inProgress) { + // Show cumulative money earned vs required to reach next milestone + double moneyRequired = plugin.getMultiplierManager().getCumulativeThreshold(i + 1); + if (moneyRequired > 0) { + double percentage = Math.min(100.0, (moneyEarned / moneyRequired) * 100.0); + lore.add(separator); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.node-earned-label", "earned: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(moneyEarned)); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.node-required-label", "required: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(moneyRequired)); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.node-progress-label", "progress: ")) + + ChatColor.YELLOW + String.format("%.1f%%", percentage)); + } + } + + if (!completed && !isStart && !inProgress) { + // Show how much more money is needed for locked nodes + double moneyNeeded = plugin.getMultiplierManager().getCumulativeThreshold(i); + double remaining = Math.max(0, moneyNeeded - moneyEarned); + if (remaining > 0) { + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("category-progress.node-need-label", "need: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(remaining) + + ChatColor.GRAY + " " + SmallCaps.convert(cfg.getText("category-progress.node-need-suffix", "more to unlock"))); + } + } + + inv.setItem(slot, makeItem(displayMat, label, lore)); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** Returns the slot index of the first path node. */ + public int getSellSlot() { + return PATH[0]; + } + + /** Check whether a given slot is the first path node. */ + public boolean isSellSlot(int slot) { + return slot == PATH[0]; + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public String getCategoryId() { + return categoryId; + } +} + diff --git a/src/main/java/com/yourname/sellplugin/gui/ConfirmSellAllGUI.java b/src/main/java/com/yourname/sellplugin/gui/ConfirmSellAllGUI.java new file mode 100644 index 0000000..96e5c29 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/ConfirmSellAllGUI.java @@ -0,0 +1,107 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.manager.SellManager; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Confirm/Cancel GUI for the "sell all" action. + * 27-slot (3 rows) GUI with Confirm (green) and Cancel (red) buttons. + */ +public class ConfirmSellAllGUI implements InventoryHolder { + + private static final int SIZE = 27; + + public static final int SLOT_CONFIRM = 11; + public static final int SLOT_CANCEL = 15; + + private final Inventory inv; + + public ConfirmSellAllGUI(SellPlugin plugin, Player player) { + ConfigManager cfg = plugin.getConfigManager(); + String title = ChatColor.DARK_GRAY + "" + ChatColor.BOLD + + SmallCaps.convert(cfg.getText("confirm-sell-all.title", "confirm sell all")); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(plugin, cfg, player); + } + + private void populate(SellPlugin plugin, ConfigManager cfg, Player player) { + // Background + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < SIZE; i++) inv.setItem(i, bg); + + // Info item in centre (slot 13) + SellManager.SellPreview preview = plugin.getSellManager().previewSellAll(player); + int itemCount = preview.itemCount; + double value = preview.value; + + String separator = cfg.getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + List infoLore = new ArrayList<>(); + infoLore.add(separator); + infoLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell-all.items-label", "items: ")) + + ChatColor.WHITE + NumberFormatter.format(itemCount)); + infoLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell-all.value-label", "value: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(value)); + infoLore.add(separator); + + inv.setItem(13, makeItem( + cfg.getIconMaterial("sell-all-info", Material.CHEST), + cfg.getIconName("sell-all-info", "&f&l" + SmallCaps.convert("sell all")), + infoLore)); + + // Confirm button + List confirmLore = new ArrayList<>(); + confirmLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell-all.confirm-lore", "sell all items from your inventory."))); + if (itemCount > 0) { + confirmLore.add(ChatColor.GREEN + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell-all.confirm-earn", "you will earn: $")) + NumberFormatter.format(value)); + } + inv.setItem(SLOT_CONFIRM, makeItem( + cfg.getIconMaterial("confirm", Material.LIME_STAINED_GLASS_PANE), + cfg.getIconName("confirm", "&a&l" + SmallCaps.convert("confirm")), + confirmLore)); + + // Cancel button + List cancelLore = cfg.getIconLore("cancel", + Collections.singletonList(ChatColor.GRAY + SmallCaps.convert(cfg.getText("confirm-sell-all.cancel-lore", "go back without selling.")))); + inv.setItem(SLOT_CANCEL, makeItem( + cfg.getIconMaterial("cancel", Material.RED_STAINED_GLASS_PANE), + cfg.getIconName("cancel", "&c&l" + SmallCaps.convert("cancel")), + cancelLore)); + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } +} + diff --git a/src/main/java/com/yourname/sellplugin/gui/ConfirmSellGUI.java b/src/main/java/com/yourname/sellplugin/gui/ConfirmSellGUI.java new file mode 100644 index 0000000..ae6a05d --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/ConfirmSellGUI.java @@ -0,0 +1,123 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Confirm/Cancel GUI for selling all items in a category. + * 27-slot (3 rows) GUI with Confirm (green) and Cancel (red) buttons. + */ +public class ConfirmSellGUI implements InventoryHolder { + + private static final int SIZE = 27; + + public static final int SLOT_CONFIRM = 11; + public static final int SLOT_CANCEL = 15; + + private final Inventory inv; + private final SellPlugin plugin; + private final String categoryId; + private final int returnPage; + + public ConfirmSellGUI(SellPlugin plugin, Player player, String categoryId, int returnPage) { + this.plugin = plugin; + this.categoryId = categoryId; + this.returnPage = returnPage; + + ConfigManager cfg = plugin.getConfigManager(); + String title = ChatColor.DARK_GRAY + "" + ChatColor.BOLD + + SmallCaps.convert(cfg.getText("confirm-sell.title-prefix", "sell your ")) + + cfg.getCategoryDisplayName(categoryId); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(player); + } + + private void populate(Player player) { + ConfigManager cfg = plugin.getConfigManager(); + + // Fill with configurable filler block + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < SIZE; i++) inv.setItem(i, bg); + + // Category info in centre (slot 13) + double value = plugin.getSellManager().calculateCategoryValue(player, categoryId); + int itemCount = plugin.getSellManager().countCategoryItems(player, categoryId); + + String separator = cfg.getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + List infoLore = new ArrayList<>(); + infoLore.add(separator); + infoLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell.items-label", "items: ")) + + ChatColor.WHITE + NumberFormatter.format(itemCount)); + infoLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell.value-label", "value: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(value)); + infoLore.add(separator); + + inv.setItem(13, makeItem(cfg.getCategoryMaterial(categoryId), + cfg.getCategoryDisplayName(categoryId), infoLore)); + + // Confirm button + List confirmLore = new ArrayList<>(); + confirmLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell.confirm-lore-line1", "sell all ")) + + cfg.getCategoryDisplayName(categoryId) + + ChatColor.GRAY + SmallCaps.convert(cfg.getText("confirm-sell.confirm-lore-line2", " items"))); + confirmLore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell.confirm-lore-line3", "from your inventory."))); + if (itemCount > 0) { + confirmLore.add(ChatColor.GREEN + " ▸ " + SmallCaps.convert(cfg.getText("confirm-sell.confirm-earn", "you will earn: $")) + NumberFormatter.format(value)); + } + inv.setItem(SLOT_CONFIRM, makeItem( + cfg.getIconMaterial("confirm", Material.LIME_STAINED_GLASS_PANE), + cfg.getIconName("confirm", "&a&l" + SmallCaps.convert("confirm")), + confirmLore)); + + // Cancel button + List cancelLore = cfg.getIconLore("cancel", + Collections.singletonList(ChatColor.GRAY + SmallCaps.convert(cfg.getText("confirm-sell.cancel-lore", "go back without selling.")))); + inv.setItem(SLOT_CANCEL, makeItem( + cfg.getIconMaterial("cancel", Material.RED_STAINED_GLASS_PANE), + cfg.getIconName("cancel", "&c&l" + SmallCaps.convert("cancel")), + cancelLore)); + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public String getCategoryId() { + return categoryId; + } + + public int getReturnPage() { + return returnPage; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/GUIListener.java b/src/main/java/com/yourname/sellplugin/gui/GUIListener.java index c7978b4..53a5b39 100644 --- a/src/main/java/com/yourname/sellplugin/gui/GUIListener.java +++ b/src/main/java/com/yourname/sellplugin/gui/GUIListener.java @@ -1,103 +1,437 @@ package com.yourname.sellplugin.gui; import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.SellManager; +import com.yourname.sellplugin.util.Scheduler; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; 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.InventoryHolder; import org.bukkit.inventory.ItemStack; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; public class GUIListener implements Listener { + private final SellPlugin plugin; public GUIListener(SellPlugin plugin) { this.plugin = plugin; } - // ANTI-DUPE: We cancel ALL drags if the top inventory is our GUI. + // ── Drag handling ──────────────────────────────────────────────────────── + @EventHandler public void onDrag(InventoryDragEvent e) { - if (e.getView().getTopInventory().getHolder() instanceof SellGUI) { + InventoryHolder holder = e.getView().getTopInventory().getHolder(); + + // ShopMainGUI: allow drags in the item-placement area (0-44), + // cancel if any slot touches the protected bottom row (45-53). + if (holder instanceof ShopMainGUI shopGUI) { + for (int slot : e.getRawSlots()) { + if (slot >= ShopMainGUI.BOTTOM_ROW_START && slot <= 53) { + e.setCancelled(true); + return; + } + } + // Allow the drag, then refresh the sell button so the value updates. + Player dragger = (e.getWhoClicked() instanceof Player p) ? p : null; + if (dragger != null) { + Scheduler.runEntityLater(plugin, dragger, shopGUI::refreshSellButton, 1L); + } + return; // allow the drag + } + + // All other plugin GUIs: cancel drags entirely. + if (holder instanceof CategoryProgressGUI + || holder instanceof CategoryItemsGUI + || holder instanceof SellAllGUI + || holder instanceof ConfirmSellGUI + || holder instanceof ConfirmSellAllGUI + || holder instanceof TopSellGUI + || holder instanceof SellMultiGUI + || holder instanceof WorthGUI) { e.setCancelled(true); } } - // ANTI-DUPE: We cancel ALL clicks if the GUI is open. + // ── Click handling ─────────────────────────────────────────────────────── + @EventHandler public void onClick(InventoryClickEvent e) { - // Check if the inventory they are viewing is our GUI - if (e.getView().getTopInventory().getHolder() instanceof SellGUI) { - e.setCancelled(true); // Cancels the click entirely so no items can move - - // We only care if they clicked the exact inventory, not their own bottom inventory - if (e.getClickedInventory() != null && e.getClickedInventory().getHolder() instanceof SellGUI) { - if (e.getSlot() == plugin.getConfigManager().getSellAllSlot()) { - Player p = (Player) e.getWhoClicked(); - processSellAll(p); - p.closeInventory(); + if (!(e.getWhoClicked() instanceof Player)) return; + Player player = (Player) e.getWhoClicked(); + + InventoryHolder holder = e.getView().getTopInventory().getHolder(); + + // ── ShopMainGUI ────────────────────────────────────────────────────── + if (holder instanceof ShopMainGUI shopGUI) { + Inventory clicked = e.getClickedInventory(); + + // Click in player inventory (bottom) – allow freely, but a + // shift-click / number-key / move-to-other-inventory action can push + // an item up into the shop area without the top inventory being the + // clicked one. Schedule a sell-button refresh so the value updates. + if (clicked != null && clicked.equals(player.getInventory())) { + Scheduler.runEntityLater(plugin, player, shopGUI::refreshSellButton, 1L); + return; + } + + if (clicked != null && clicked.getHolder() instanceof ShopMainGUI) { + int slot = e.getSlot(); + + // Bottom row (45-53): protected – handle sell button + if (slot >= ShopMainGUI.BOTTOM_ROW_START) { + e.setCancelled(true); + if (slot == ShopMainGUI.SLOT_SELL_BUTTON) { + // Sell all items in the GUI + sellGuiItems(player, shopGUI); + } + return; } + + // Slots 0-44: allow item placement / removal + // After any click, schedule a sell button refresh (on the + // player's own region thread for Folia compatibility). + Scheduler.runEntityLater(plugin, player, shopGUI::refreshSellButton, 1L); + return; + } + + e.setCancelled(true); + return; + } + + // ── SellMultiGUI ───────────────────────────────────────────────────── + if (holder instanceof SellMultiGUI multiGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof SellMultiGUI)) return; + + int slot = e.getSlot(); + String catId = multiGUI.getCategoryAtSlot(slot); + if (catId != null) { + new CategoryProgressGUI(plugin, player, catId).open(player); + } + return; + } + + // ── WorthGUI ───────────────────────────────────────────────────────── + if (holder instanceof WorthGUI worthGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof WorthGUI)) return; + + int slot = e.getSlot(); + + if (slot == WorthGUI.SLOT_CLOSE) { + player.closeInventory(); + return; + } + + if (slot == WorthGUI.SLOT_PREV && worthGUI.hasPrevPage()) { + worthGUI.prevPage().open(player); + return; + } + + if (slot == WorthGUI.SLOT_NEXT && worthGUI.hasNextPage()) { + worthGUI.nextPage().open(player); + return; + } + + if (slot == WorthGUI.SLOT_FILTER) { + String nextFilter = worthGUI.getNextFilter(); + new WorthGUI(plugin, player, nextFilter, 0).open(player); + return; + } + return; + } + + // ── CategoryProgressGUI ────────────────────────────────────────────── + if (holder instanceof CategoryProgressGUI catProgressGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof CategoryProgressGUI)) return; + + int slot = e.getSlot(); + + if (slot == CategoryProgressGUI.SLOT_BACK) { + new SellMultiGUI(plugin, player).open(player); + return; + } + + // Click the first path node → open items list for this category + if (catProgressGUI.isSellSlot(slot)) { + new CategoryItemsGUI(plugin, player, catProgressGUI.getCategoryId(), 0).open(player); + return; + } + return; + } + + // ── ConfirmSellGUI ─────────────────────────────────────────────────── + if (holder instanceof ConfirmSellGUI confirmGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof ConfirmSellGUI)) return; + + int slot = e.getSlot(); + + if (slot == ConfirmSellGUI.SLOT_CONFIRM) { + player.closeInventory(); + plugin.getSellManager().sellCategory(player, confirmGUI.getCategoryId()); + return; + } + + if (slot == ConfirmSellGUI.SLOT_CANCEL) { + new CategoryItemsGUI(plugin, player, confirmGUI.getCategoryId(), confirmGUI.getReturnPage()).open(player); + return; + } + return; + } + + // ── CategoryItemsGUI ───────────────────────────────────────────────── + if (holder instanceof CategoryItemsGUI catItemsGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof CategoryItemsGUI)) return; + + int slot = e.getSlot(); + + if (slot == CategoryItemsGUI.SLOT_BACK) { + new SellMultiGUI(plugin, player).open(player); + return; + } + + if (slot == CategoryItemsGUI.SLOT_PREV && catItemsGUI.hasPrevPage()) { + catItemsGUI.prevPage().open(player); + return; + } + + if (slot == CategoryItemsGUI.SLOT_NEXT && catItemsGUI.hasNextPage()) { + catItemsGUI.nextPage().open(player); + return; + } + + if (slot == CategoryItemsGUI.SLOT_SELL_ALL) { + new ConfirmSellGUI(plugin, player, catItemsGUI.getCategoryId(), catItemsGUI.getPage()).open(player); + return; + } + + // Item click (slots 0-44) – sell all of that item type + String itemKey = catItemsGUI.getItemKeyAtSlot(slot); + if (itemKey != null) { + plugin.getSellManager().sellItemType(player, itemKey); + new CategoryItemsGUI(plugin, player, catItemsGUI.getCategoryId(), + catItemsGUI.getPage()).open(player); + } + return; + } + + // ── SellAllGUI ─────────────────────────────────────────────────────── + if (holder instanceof SellAllGUI sellAllGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof SellAllGUI)) return; + + if (e.getSlot() == sellAllGUI.getSellAllSlot()) { + new ConfirmSellAllGUI(plugin, player).open(player); + } + } + + // ── ConfirmSellAllGUI ───────────────────────────────────────────────── + if (holder instanceof ConfirmSellAllGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof ConfirmSellAllGUI)) return; + + int slot = e.getSlot(); + + if (slot == ConfirmSellAllGUI.SLOT_CONFIRM) { + player.closeInventory(); + plugin.getSellManager().sellAll(player); + return; + } + + if (slot == ConfirmSellAllGUI.SLOT_CANCEL) { + player.closeInventory(); + new SellAllGUI(plugin, player).open(player); + } + } + + // ── TopSellGUI ──────────────────────────────────────────────────────── + if (holder instanceof TopSellGUI topSellGUI) { + e.setCancelled(true); + if (e.getClickedInventory() == null + || !(e.getClickedInventory().getHolder() instanceof TopSellGUI)) return; + + int slot = e.getSlot(); + + if (slot == TopSellGUI.SLOT_CLOSE) { + player.closeInventory(); + return; + } + + if (slot == TopSellGUI.SLOT_PREV && topSellGUI.hasPrevPage()) { + topSellGUI.prevPage().open(player); + return; + } + + if (slot == TopSellGUI.SLOT_NEXT && topSellGUI.hasNextPage()) { + topSellGUI.nextPage().open(player); } } } - private void processSellAll(Player p) { - Inventory pInv = p.getInventory(); + // ── Sell items placed in the ShopMainGUI (via button click) ───────────── + + private void sellGuiItems(Player player, ShopMainGUI shopGUI) { + Inventory top = shopGUI.getInventory(); + SellPlugin pl = shopGUI.getPlugin(); + double totalEarned = 0.0; - int totalItemsSold = 0; - - // Track how many of each category we sold to batch-save stats at the end - Map categorySales = new HashMap<>(); + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + List sellableItems = new ArrayList<>(); + List nonSellableItems = new ArrayList<>(); - for (int i = 0; i < pInv.getSize(); i++) { - ItemStack item = pInv.getItem(i); + for (int i = 0; i < ShopMainGUI.ITEM_AREA_END; i++) { + ItemStack item = top.getItem(i); if (item == null || item.getType() == Material.AIR) continue; - String itemKey = plugin.getPriceManager().getItemKey(item); - if (itemKey == null) continue; + // Shulker box: sell its contents, return the shulker + if (SellManager.isShulkerBox(item)) { + SellManager.ShulkerSellData data = pl.getSellManager().sellShulkerContents(player, item, null, null); + totalEarned += data.earned; + totalItems += data.items; + data.categoryEarnings.forEach((cat, val) -> categoryEarnings.merge(cat, val, Double::sum)); + nonSellableItems.add(item); // return shulker box + top.setItem(i, null); + continue; + } - double basePrice = plugin.getPriceManager().getPrice(itemKey); - - if (basePrice > 0) { - String category = plugin.getPriceManager().getCategory(itemKey); - double multiplier = plugin.getMultiplierManager().getMultiplier(p, category); - - int amount = item.getAmount(); - double finalPrice = (basePrice * multiplier) * amount; + String key = pl.getPriceManager().getItemKey(item); + if (key == null || pl.getPriceManager().getPrice(key) <= 0) { + nonSellableItems.add(item); + top.setItem(i, null); + continue; + } - totalEarned += finalPrice; - totalItemsSold += amount; - - categorySales.put(category, categorySales.getOrDefault(category, 0) + amount); - - // Remove item securely - pInv.setItem(i, null); + double base = pl.getPriceManager().getPrice(key); + String cat = pl.getPriceManager().getCategory(key); + double mult = pl.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = item.getAmount(); + double earned = pl.getSellManager().enchantedUnitPrice(item, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + sellableItems.add(item); + top.setItem(i, null); + } + + for (ItemStack item : nonSellableItems) { + returnItem(player, item); + } + + if (totalEarned > 0) { + boolean ok = pl.getEconomyManager().deposit(player, totalEarned); + if (ok) { + for (Map.Entry entry : categoryEarnings.entrySet()) { + pl.getMultiplierManager().addEarnings(player, entry.getKey(), entry.getValue()); + } + pl.getSellManager().sendSellNotification(player, totalEarned, totalItems); + } else { + player.sendMessage(pl.getConfigManager().getMessage("economy-error")); + for (ItemStack item : sellableItems) { + returnItem(player, item); + } + } + } + + // Refresh the sell button after selling + shopGUI.refreshSellButton(); + } + + // ── Close handling – sell items placed in ShopMainGUI ──────────────────── + + @EventHandler + public void onClose(InventoryCloseEvent e) { + if (!(e.getPlayer() instanceof Player player)) return; + + InventoryHolder holder = e.getView().getTopInventory().getHolder(); + if (!(holder instanceof ShopMainGUI shopGUI)) return; + + Inventory top = e.getView().getTopInventory(); + SellPlugin pl = shopGUI.getPlugin(); + + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + List sellableItems = new ArrayList<>(); + List nonSellableItems = new ArrayList<>(); + + for (int i = 0; i < ShopMainGUI.ITEM_AREA_END; i++) { + ItemStack item = top.getItem(i); + if (item == null || item.getType() == Material.AIR) continue; + + // Shulker box: sell its contents, return the (now empty/partially-empty) shulker + if (SellManager.isShulkerBox(item)) { + SellManager.ShulkerSellData data = pl.getSellManager().sellShulkerContents(player, item, null, null); + totalEarned += data.earned; + totalItems += data.items; + data.categoryEarnings.forEach((cat, val) -> categoryEarnings.merge(cat, val, Double::sum)); + // Return the shulker box (now with sold items removed) to the player + returnItem(player, item); + continue; + } + + String key = pl.getPriceManager().getItemKey(item); + if (key == null || pl.getPriceManager().getPrice(key) <= 0) { + nonSellableItems.add(item); + continue; } + + double base = pl.getPriceManager().getPrice(key); + String cat = pl.getPriceManager().getCategory(key); + double mult = pl.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = item.getAmount(); + double earned = pl.getSellManager().enchantedUnitPrice(item, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + sellableItems.add(item); + } + + for (ItemStack item : nonSellableItems) { + returnItem(player, item); } if (totalEarned > 0) { - // Apply money - boolean success = plugin.getEconomyManager().deposit(p, totalEarned); - if (success) { - // Update Multipliers only if economy transaction succeeded - for (Map.Entry entry : categorySales.entrySet()) { - plugin.getMultiplierManager().addSales(p, entry.getKey(), entry.getValue()); + boolean ok = pl.getEconomyManager().deposit(player, totalEarned); + if (ok) { + for (Map.Entry entry : categoryEarnings.entrySet()) { + pl.getMultiplierManager().addEarnings(player, entry.getKey(), entry.getValue()); } - - String msg = plugin.getConfigManager().getMessage("sold-items") - .replace("{amount}", String.valueOf(totalItemsSold)) - .replace("{price}", String.format("%.2f", totalEarned)); - p.sendMessage(msg); + pl.getSellManager().sendSellNotification(player, totalEarned, totalItems); } else { - p.sendMessage(plugin.getConfigManager().getMessage("economy-error")); + player.sendMessage(pl.getConfigManager().getMessage("economy-error")); + for (ItemStack item : sellableItems) { + returnItem(player, item); + } } - } else { - p.sendMessage(plugin.getConfigManager().getMessage("nothing-to-sell")); + } + } + + private void returnItem(Player player, ItemStack item) { + HashMap leftover = player.getInventory().addItem(item); + for (ItemStack drop : leftover.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), drop); } } } diff --git a/src/main/java/com/yourname/sellplugin/gui/SellAllGUI.java b/src/main/java/com/yourname/sellplugin/gui/SellAllGUI.java new file mode 100644 index 0000000..01ed55c --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/SellAllGUI.java @@ -0,0 +1,101 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.util.NumberFormatter; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Simple /sellall GUI – configurable size / item / slot. + * A single "Sell All" button in the centre. + */ +public class SellAllGUI implements InventoryHolder { + + private final Inventory inv; + private final SellPlugin plugin; + private final int sellAllSlot; + + public SellAllGUI(SellPlugin plugin, Player player) { + this.plugin = plugin; + ConfigManager cfg = plugin.getConfigManager(); + this.sellAllSlot = cfg.getSellAllSlot(); + + int size = cfg.getSellAllGuiSize(); + // Clamp to valid inventory sizes (multiples of 9, 9-54) + if (size < 9 || size > 54 || size % 9 != 0) size = 27; + + this.inv = Bukkit.createInventory(this, size, cfg.getSellAllGuiTitle()); + populate(player); + } + + private void populate(Player player) { + ConfigManager cfg = plugin.getConfigManager(); + + // Background (uses configurable filler block) + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < inv.getSize(); i++) inv.setItem(i, bg); + + // Sell All button + Material mat = Material.matchMaterial(cfg.getSellAllMaterial()); + if (mat == null) mat = Material.EMERALD_BLOCK; + + Set categories = plugin.getPriceManager().getCategories(); + List lore = new ArrayList<>(); + for (String raw : cfg.getSellAllLore()) { + if (raw.contains("{multipliers}")) { + for (String cat : categories) { + double m = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + lore.add(ChatColor.translateAlternateColorCodes('&', + "&e \u25b6 &f" + cat + ": &a" + NumberFormatter.format(m) + "x")); + } + } else { + lore.add(ChatColor.translateAlternateColorCodes('&', raw)); + } + } + + int slot = Math.min(sellAllSlot, inv.getSize() - 1); + if (sellAllSlot >= inv.getSize()) { + plugin.getLogger().warning("sell-all-gui.slot (" + sellAllSlot + + ") exceeds inventory size (" + inv.getSize() + + "). Placing button at slot " + slot + "."); + } + inv.setItem(slot, makeItem(mat, cfg.getSellAllName(), lore)); + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public int getSellAllSlot() { + return sellAllSlot; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/SellGUI.java b/src/main/java/com/yourname/sellplugin/gui/SellGUI.java deleted file mode 100644 index 6dd64ce..0000000 --- a/src/main/java/com/yourname/sellplugin/gui/SellGUI.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.yourname.sellplugin.gui; - -import com.yourname.sellplugin.SellPlugin; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -public class SellGUI implements InventoryHolder { - private final Inventory inv; - private final SellPlugin plugin; - - public SellGUI(SellPlugin plugin, Player player) { - this.plugin = plugin; - String title = plugin.getConfigManager().getGuiTitle(); - int size = plugin.getConfigManager().getGuiSize(); - - this.inv = Bukkit.createInventory(this, size, title); - - setupItems(player); - } - - private void setupItems(Player player) { - int slot = plugin.getConfigManager().getSellAllSlot(); - Material mat = Material.matchMaterial(plugin.getConfigManager().getSellAllMaterial()); - if (mat == null) mat = Material.EMERALD_BLOCK; - - ItemStack sellAllBtn = new ItemStack(mat); - ItemMeta meta = sellAllBtn.getItemMeta(); - if (meta != null) { - meta.setDisplayName(plugin.getConfigManager().getSellAllName()); - - // Build the lore dynamically to show multipliers - List rawLore = plugin.getConfigManager().getSellAllLore(); - List finalLore = new ArrayList<>(); - - Set categories = plugin.getPriceManager().getCategories(); - - for (String line : rawLore) { - if (line.contains("{multipliers}")) { - for (String cat : categories) { - double multi = plugin.getMultiplierManager().getMultiplier(player, cat); - String formatted = String.format("%.2f", multi); - finalLore.add(ChatColor.translateAlternateColorCodes('&', "&e \u25b6 &f" + cat + ": &a" + formatted + "x")); - } - } else { - finalLore.add(ChatColor.translateAlternateColorCodes('&', line)); - } - } - - meta.setLore(finalLore); - sellAllBtn.setItemMeta(meta); - } - - inv.setItem(slot, sellAllBtn); - } - - @Override - public Inventory getInventory() { - return inv; - } - - public void open(Player player) { - player.openInventory(inv); - } -} diff --git a/src/main/java/com/yourname/sellplugin/gui/SellMultiGUI.java b/src/main/java/com/yourname/sellplugin/gui/SellMultiGUI.java new file mode 100644 index 0000000..1acefb7 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/SellMultiGUI.java @@ -0,0 +1,106 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 1×9 multiplier overview GUI opened via /sellmulti. + * Shows all category multipliers at a glance. + */ +public class SellMultiGUI implements InventoryHolder { + + private static final int SIZE = 9; + + private final Inventory inv; + private final SellPlugin plugin; + private final Player player; + + public SellMultiGUI(SellPlugin plugin, Player player) { + this.plugin = plugin; + this.player = player; + + ConfigManager cfg = plugin.getConfigManager(); + String title = cfg.getText("sellmulti.title", "&8&lMultipliers"); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(); + } + + private void populate() { + ConfigManager cfg = plugin.getConfigManager(); + List catOrder = cfg.getCategoryOrder(); + + // Fill with filler + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < SIZE; i++) inv.setItem(i, bg); + + for (int i = 0; i < Math.min(SIZE, catOrder.size()); i++) { + inv.setItem(i, buildMultiplierIcon(catOrder.get(i))); + } + } + + private ItemStack buildMultiplierIcon(String catId) { + ConfigManager cfg = plugin.getConfigManager(); + + double multiplier = plugin.getMultiplierManager().getMultiplier(player, catId); + double effective = multiplier; + + String separator = cfg.getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + + List lore = new ArrayList<>(); + lore.add(separator); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("sellmulti.earned-label", "earned: ")) + + ChatColor.AQUA + String.format("%.2fx", multiplier)); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("sellmulti.effective-label", "effective: ")) + + ChatColor.GREEN + String.format("%.2fx", effective)); + lore.add(separator); + lore.add(ChatColor.YELLOW + " ✦ " + SmallCaps.convert(cfg.getText("sellmulti.click-to-view", "click to view progress"))); + + return makeItem(cfg.getCategoryMaterial(catId), cfg.getCategoryDisplayName(catId), lore); + } + + /** Returns the category ID for a slot, or null if not a category slot. */ + public String getCategoryAtSlot(int slot) { + if (slot < 0 || slot >= SIZE) return null; + List order = plugin.getConfigManager().getCategoryOrder(); + if (slot < order.size()) return order.get(slot); + return null; + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public SellPlugin getPlugin() { + return plugin; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/ShopMainGUI.java b/src/main/java/com/yourname/sellplugin/gui/ShopMainGUI.java new file mode 100644 index 0000000..c4dfae7 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/ShopMainGUI.java @@ -0,0 +1,142 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.*; + +/** + * Main 9×6 sell GUI. + * Rows 0-4 (slots 0-44): empty area – players can place items here to sell. + * Row 5 (slot 53): Sell button (lime glass pane, bottom-right). + * + * When the GUI is closed, every sellable item left in the placement area is sold + * automatically and non-sellable items are returned to the player. + * Clicking the Sell button also triggers selling all items. + */ +public class ShopMainGUI implements InventoryHolder { + + private static final int ROWS = 6; + private static final int SIZE = ROWS * 9; // 54 + + /** First slot of the protected bottom row (Sell button row). */ + public static final int BOTTOM_ROW_START = 45; + + /** The Sell button slot (bottom-right corner). */ + public static final int SLOT_SELL_BUTTON = 53; + + /** Number of item placement slots (rows 0-4). */ + public static final int ITEM_AREA_END = 45; + + private final Inventory inv; + private final SellPlugin plugin; + private final Player player; + + public ShopMainGUI(SellPlugin plugin, Player player) { + this.plugin = plugin; + this.player = player; + // Title in small caps (configurable via messages.shop.title) + String title = ChatColor.DARK_GRAY + "" + ChatColor.BOLD + + SmallCaps.convert(plugin.getConfigManager().getText("shop.title", "put items here to sell")); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(); + } + + private void populate() { + ConfigManager cfg = plugin.getConfigManager(); + + // Rows 0-4 (slots 0-44) are left EMPTY for item placement. + + // Fill bottom row with filler glass + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int slot = BOTTOM_ROW_START; slot < SIZE; slot++) inv.setItem(slot, bg); + + // Sell button in bottom-right corner (slot 53) + inv.setItem(SLOT_SELL_BUTTON, buildSellButton()); + } + + /** + * Rebuild the sell button with updated value preview. + * Call this to refresh the hover text showing sell value. + */ + public void refreshSellButton() { + inv.setItem(SLOT_SELL_BUTTON, buildSellButton()); + } + + private ItemStack buildSellButton() { + ConfigManager cfg = plugin.getConfigManager(); + + // Calculate the value of items currently in the GUI + double totalValue = calculateGuiItemsValue(); + + String separator = cfg.getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + + List lore = new ArrayList<>(); + lore.add(separator); + if (totalValue > 0) { + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("shop.sell-value-label", "value: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(totalValue)); + } else { + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(cfg.getText("shop.sell-empty", "no sellable items"))); + } + lore.add(separator); + lore.add(ChatColor.YELLOW + " ✦ " + SmallCaps.convert(cfg.getText("shop.sell-click", "click to sell all items!"))); + + String sellButtonName = cfg.getText("shop.sell-button-name", "&a&lSell"); + + Material sellMat = cfg.getIconMaterial("sell-button", Material.LIME_STAINED_GLASS_PANE); + return makeItem(sellMat, sellButtonName, lore); + } + + /** + * Calculate the total sell value of all items currently placed in the GUI. + */ + public double calculateGuiItemsValue() { + double totalValue = 0.0; + for (int i = 0; i < ITEM_AREA_END; i++) { + ItemStack item = inv.getItem(i); + if (item == null || item.getType() == Material.AIR) continue; + totalValue += plugin.getSellManager().calculateItemWorth(player, item); + } + return totalValue; + } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } + + public SellPlugin getPlugin() { + return plugin; + } + + public Player getPlayer() { + return player; + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java b/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java new file mode 100644 index 0000000..3734f6a --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java @@ -0,0 +1,215 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.manager.MultiplierManager.LeaderboardEntry; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.Scheduler; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Top-sellers leaderboard GUI – full double-chest (54 slots). + * + * Rows 0-4 (slots 0-44): up to 36 player entries per page (4 rows × 9). + * Row 5 (slots 45-53): navigation bar. + * 45 – Close + * 48 – Previous page (directly left of page indicator) + * 49 – Page indicator + * 50 – Next page (directly right of page indicator) + * + * Each entry is a player-head ItemStack with: + * - Display name: rank + player name + * - Lore: total earnings + * + * Player head skins are set via SkullMeta#setOwningPlayer(OfflinePlayer). + * The Minecraft client resolves and caches the actual texture, so the server + * itself does not directly call the Mojang API per-request. + * To further protect against any server-side profile lookups, heads are + * scheduled with a 2-tick delay between each other. + */ +public class TopSellGUI implements InventoryHolder { + + private static final int SIZE = 54; + private static final int ENTRIES_PER_PAGE = 36; // rows 0-3 (4 × 9) + + public static final int SLOT_CLOSE = 45; + public static final int SLOT_PREV = 48; + public static final int SLOT_INFO = 49; + public static final int SLOT_NEXT = 50; + + private final Inventory inv; + private final SellPlugin plugin; + private final Player viewer; + private final List entries; + private final int page; // 0-based + + public TopSellGUI(SellPlugin plugin, Player viewer, int page) { + this.plugin = plugin; + this.viewer = viewer; + this.entries = plugin.getMultiplierManager().getLeaderboard(); + this.page = page; + + ConfigManager cfg = plugin.getConfigManager(); + String title = ChatColor.DARK_GRAY + "" + ChatColor.BOLD + + SmallCaps.convert(cfg.getText("top-sell.title", "top sellers")); + this.inv = Bukkit.createInventory(this, SIZE, title); + populate(); + } + + // ── Layout ─────────────────────────────────────────────────────────────── + + private void populate() { + ConfigManager cfg = plugin.getConfigManager(); + + // Fill entire GUI with filler + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 0; i < SIZE; i++) inv.setItem(i, bg); + + // ── Player entries (rows 0-3) ──────────────────────────────────────── + int start = page * ENTRIES_PER_PAGE; + int end = Math.min(start + ENTRIES_PER_PAGE, entries.size()); + + for (int i = start; i < end; i++) { + final int slot = i - start; + LeaderboardEntry entry = entries.get(i); + int rank = i + 1; + // Schedule each skull with a small staggered delay to avoid any + // potential server-side profile look-up spikes (2 ticks apart). + // Runs on the viewer's region thread so it is Folia-safe. + Scheduler.runEntityLater(plugin, viewer, () -> { + if (viewer.isOnline()) { + inv.setItem(slot, buildEntryHead(entry, rank)); + } + }, (long) slot * 2L); + } + + // ── Navigation bar (row 5) ────────────────────────────────────────── + + // Close button + List closeLore = cfg.getIconLore("topsell-close", + Collections.singletonList(ChatColor.GRAY + SmallCaps.convert(cfg.getText("top-sell.close-lore", "close the leaderboard.")))); + inv.setItem(SLOT_CLOSE, makeItem( + cfg.getIconMaterial("topsell-close", Material.BARRIER), + cfg.getIconName("topsell-close", "&c&l" + SmallCaps.convert("close")), + closeLore)); + + // Previous page + if (page > 0) { + List prevLore = cfg.getIconLore("prev-page", + Collections.singletonList(ChatColor.GRAY + SmallCaps.convert(cfg.getText("top-sell.prev-page-lore", "previous page.")))); + inv.setItem(SLOT_PREV, makeItem( + cfg.getIconMaterial("prev-page", Material.ARROW), + cfg.getIconName("prev-page", "&e← " + SmallCaps.convert("previous")), + prevLore)); + } + + // Page indicator + int totalPages = Math.max(1, (int) Math.ceil((double) entries.size() / ENTRIES_PER_PAGE)); + List infoLore = Collections.singletonList( + ChatColor.GRAY + SmallCaps.convert(cfg.getText("top-sell.total-players", "total players: ")) + entries.size()); + inv.setItem(SLOT_INFO, makeItem( + cfg.getIconMaterial("page-indicator", Material.PAPER), + ChatColor.WHITE + SmallCaps.convert(cfg.getText("top-sell.page-indicator", "page {page} / {total}") + .replace("{page}", String.valueOf(page + 1)) + .replace("{total}", String.valueOf(totalPages))), + infoLore)); + + // Next page + if ((page + 1) * ENTRIES_PER_PAGE < entries.size()) { + List nextLore = cfg.getIconLore("next-page", + Collections.singletonList(ChatColor.GRAY + SmallCaps.convert(cfg.getText("top-sell.next-page-lore", "next page.")))); + inv.setItem(SLOT_NEXT, makeItem( + cfg.getIconMaterial("next-page", Material.ARROW), + cfg.getIconName("next-page", "&e" + SmallCaps.convert("next") + " →"), + nextLore)); + } + } + + // ── Entry head builder ─────────────────────────────────────────────────── + + private ItemStack buildEntryHead(LeaderboardEntry entry, int rank) { + ItemStack skull = new ItemStack(Material.PLAYER_HEAD); + SkullMeta meta = (SkullMeta) skull.getItemMeta(); + if (meta == null) return skull; + + // Set skin (uses server's cached profile data; client fetches texture) + OfflinePlayer op = Bukkit.getOfflinePlayer(entry.uuid); + meta.setOwningPlayer(op); + + // Display name: rank + player name + ChatColor rankColour = rankColour(rank); + meta.setDisplayName(rankColour + "#" + rank + " " + ChatColor.WHITE + entry.name); + + // Lore: total earnings + List lore = new ArrayList<>(); + String separator = plugin.getConfigManager().getText("lore-separator", "&8━━━━━━━━━━━━━━━━━━━"); + lore.add(separator); + lore.add(ChatColor.GRAY + " ▸ " + SmallCaps.convert(plugin.getConfigManager().getText("top-sell.total-earned-label", "total earned: ")) + + ChatColor.GREEN + "$" + NumberFormatter.format(entry.totalEarnings)); + lore.add(separator); + meta.setLore(lore); + + skull.setItemMeta(meta); + return skull; + } + + private ChatColor rankColour(int rank) { + if (rank == 1) return ChatColor.GOLD; + if (rank == 2) return ChatColor.GRAY; + if (rank == 3) return ChatColor.DARK_RED; + return ChatColor.WHITE; + } + + // ── Navigation helpers ─────────────────────────────────────────────────── + + public boolean hasPrevPage() { return page > 0; } + + public boolean hasNextPage() { + return (page + 1) * ENTRIES_PER_PAGE < entries.size(); + } + + public TopSellGUI prevPage() { + return new TopSellGUI(plugin, viewer, page - 1); + } + + public TopSellGUI nextPage() { + return new TopSellGUI(plugin, viewer, page + 1); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } +} diff --git a/src/main/java/com/yourname/sellplugin/gui/WorthGUI.java b/src/main/java/com/yourname/sellplugin/gui/WorthGUI.java new file mode 100644 index 0000000..4d7b3bf --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/gui/WorthGUI.java @@ -0,0 +1,288 @@ +package com.yourname.sellplugin.gui; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.manager.ConfigManager; +import com.yourname.sellplugin.manager.PriceManager; +import com.yourname.sellplugin.util.ItemNameFormatter; +import com.yourname.sellplugin.util.NumberFormatter; +import com.yourname.sellplugin.util.SmallCaps; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionData; +import org.bukkit.potion.PotionType; + +import java.util.*; + +/** + * Item Prices GUI opened via /sellworth or /worth. + * Paginated 6×9 (54 slots) with category filter support. + * + * Layout: + * Rows 0-4 (slots 0-44): item display + * Row 5 (slots 45-53): navigation bar + * 45 – Back/Close + * 47 – Previous page + * 48 – Filter button + * 49 – Page indicator + * 50 – Filter button (right) + * 51 – Next page + * 53 – (unused) + */ +public class WorthGUI implements InventoryHolder { + + private static final int ITEMS_PER_PAGE = 45; + + // Navigation slots + public static final int SLOT_CLOSE = 45; + public static final int SLOT_PREV = 47; + public static final int SLOT_FILTER = 48; + public static final int SLOT_INFO = 49; + public static final int SLOT_NEXT = 51; + + // Filter categories (null = all) + private static final String FILTER_ALL = "all"; + + private final Inventory inv; + private final SellPlugin plugin; + private final Player player; + private final String filter; // category filter or "all" + private final List itemKeys; + private int page; + + public WorthGUI(SellPlugin plugin, Player player, String filter, int page) { + this.plugin = plugin; + this.player = player; + this.filter = filter != null ? filter : FILTER_ALL; + this.page = page; + this.itemKeys = buildItemKeyList(); + + ConfigManager cfg = plugin.getConfigManager(); + String titleBase = cfg.getText("worth-gui.title", "&8&lItem Prices"); + String pageStr = " (Page " + (page + 1) + ")"; + this.inv = Bukkit.createInventory(this, 54, titleBase + pageStr); + populate(); + } + + private List buildItemKeyList() { + PriceManager pm = plugin.getPriceManager(); + List keys = new ArrayList<>(); + for (String key : pm.getAllItemKeys()) { + if (FILTER_ALL.equals(filter) || filter.equalsIgnoreCase(pm.getCategory(key))) { + keys.add(key); + } + } + Collections.sort(keys); + return keys; + } + + private void populate() { + inv.clear(); + ConfigManager cfg = plugin.getConfigManager(); + + // Background for navigation row + Material fillerMat = cfg.getFillerBlock(); + ItemStack bg = makeItem(fillerMat, " ", Collections.emptyList()); + for (int i = 45; i < 54; i++) inv.setItem(i, bg); + + // Items area + int start = page * ITEMS_PER_PAGE; + int end = Math.min(start + ITEMS_PER_PAGE, itemKeys.size()); + for (int i = start; i < end; i++) { + inv.setItem(i - start, buildItemDisplay(itemKeys.get(i))); + } + + // Fill remaining item area + ItemStack filler = makeItem(Material.GRAY_STAINED_GLASS_PANE, " ", Collections.emptyList()); + for (int i = (end - start); i < 45; i++) inv.setItem(i, filler); + + // Close button + List closeLore = cfg.getIconLore("topsell-close", + Collections.singletonList(ChatColor.GRAY + "Close the menu.")); + inv.setItem(SLOT_CLOSE, makeItem( + cfg.getIconMaterial("topsell-close", Material.BARRIER), + cfg.getIconName("topsell-close", "&c&lClose"), + closeLore)); + + // Previous page + if (page > 0) { + List prevLore = cfg.getIconLore("prev-page", + Collections.singletonList(ChatColor.GRAY + "Previous page.")); + inv.setItem(SLOT_PREV, makeItem( + cfg.getIconMaterial("prev-page", Material.ARROW), + cfg.getIconName("prev-page", "&e← Previous"), + prevLore)); + } + + // Filter button + List filterLore = buildFilterLore(); + String filterName = FILTER_ALL.equals(filter) + ? cfg.getText("worth-gui.filter-all", "&e&lFILTER: &fAll") + : cfg.getText("worth-gui.filter-category", "&e&lFILTER: &f") + cfg.getCategoryDisplayName(filter); + inv.setItem(SLOT_FILTER, makeItem( + Material.HOPPER, + filterName, + filterLore)); + + // Page indicator + int totalPages = Math.max(1, (int) Math.ceil((double) itemKeys.size() / ITEMS_PER_PAGE)); + List infoLore = Collections.singletonList( + ChatColor.GRAY + "Total items: " + itemKeys.size()); + inv.setItem(SLOT_INFO, makeItem( + cfg.getIconMaterial("page-indicator", Material.PAPER), + ChatColor.WHITE + "Page " + (page + 1) + " / " + totalPages, + infoLore)); + + // Next page + if ((page + 1) * ITEMS_PER_PAGE < itemKeys.size()) { + List nextLore = cfg.getIconLore("next-page", + Collections.singletonList(ChatColor.GRAY + "Next page.")); + inv.setItem(SLOT_NEXT, makeItem( + cfg.getIconMaterial("next-page", Material.ARROW), + cfg.getIconName("next-page", "&eNext →"), + nextLore)); + } + } + + private List buildFilterLore() { + ConfigManager cfg = plugin.getConfigManager(); + List lore = new ArrayList<>(); + lore.add(ChatColor.GRAY + "Click to cycle filter."); + lore.add(""); + + List categories = cfg.getCategoryOrder(); + // Show current filter highlighted + if (FILTER_ALL.equals(filter)) { + lore.add(ChatColor.GREEN + " • All"); + } else { + lore.add(ChatColor.GRAY + " • All"); + } + for (String cat : categories) { + String displayName = ChatColor.stripColor(cfg.getCategoryDisplayName(cat)); + if (cat.equalsIgnoreCase(filter)) { + lore.add(ChatColor.GREEN + " • " + displayName); + } else { + lore.add(ChatColor.GRAY + " • " + displayName); + } + } + return lore; + } + + private ItemStack buildItemDisplay(String itemKey) { + PriceManager pm = plugin.getPriceManager(); + double base = pm.getPrice(itemKey); + String category = pm.getCategory(itemKey); + double effectiveMultiplier = plugin.getMultiplierManager().getEffectiveMultiplier(player, category); + double effective = base * effectiveMultiplier; + + ItemStack item = resolveItemStack(itemKey); + + List lore = new ArrayList<>(); + lore.add(ChatColor.DARK_GRAY + "━━━━━━━━━━━━━━━━━━━━━"); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Base: " + + ChatColor.GREEN + "$" + NumberFormatter.format(base)); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Mult: " + + ChatColor.AQUA + String.format("%.2fx", effectiveMultiplier)); + lore.add(ChatColor.GRAY + " ▸ " + ChatColor.WHITE + "Price: " + + ChatColor.GREEN + "$" + NumberFormatter.format(effective)); + lore.add(ChatColor.DARK_GRAY + "━━━━━━━━━━━━━━━━━━━━━"); + lore.add(ChatColor.GRAY + " Category: " + ChatColor.WHITE + + ChatColor.stripColor(plugin.getConfigManager().getCategoryDisplayName(category))); + + String displayName = ChatColor.WHITE + ItemNameFormatter.formatKey(itemKey); + + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(displayName); + meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack resolveItemStack(String itemKey) { + if (itemKey.contains(":")) { + String[] parts = itemKey.split(":", 2); + Material mat = Material.matchMaterial(parts[0]); + if (mat == null || mat.isAir() || !mat.isItem()) return new ItemStack(Material.BARRIER); + + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta instanceof PotionMeta potionMeta) { + try { + PotionType type = PotionType.valueOf(parts[1]); + potionMeta.setBasePotionData(new PotionData(type)); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) {} + } + return item; + } + Material mat = Material.matchMaterial(itemKey); + if (mat == null || mat.isAir() || !mat.isItem()) return new ItemStack(Material.BARRIER); + return new ItemStack(mat); + } + + /** Cycle to the next filter category. */ + public String getNextFilter() { + List categories = plugin.getConfigManager().getCategoryOrder(); + if (FILTER_ALL.equals(filter)) { + return categories.isEmpty() ? FILTER_ALL : categories.get(0); + } + int idx = -1; + for (int i = 0; i < categories.size(); i++) { + if (categories.get(i).equalsIgnoreCase(filter)) { + idx = i; + break; + } + } + if (idx < 0 || idx >= categories.size() - 1) { + return FILTER_ALL; + } + return categories.get(idx + 1); + } + + // ── Navigation ─────────────────────────────────────────────────────────── + + public boolean hasPrevPage() { return page > 0; } + + public boolean hasNextPage() { + return (page + 1) * ITEMS_PER_PAGE < itemKeys.size(); + } + + public WorthGUI prevPage() { + return new WorthGUI(plugin, player, filter, page - 1); + } + + public WorthGUI nextPage() { + return new WorthGUI(plugin, player, filter, page + 1); + } + + public String getFilter() { return filter; } + public int getPage() { return page; } + + private ItemStack makeItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + if (!lore.isEmpty()) meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + @Override + public Inventory getInventory() { + return inv; + } + + public void open(Player p) { + p.openInventory(inv); + } +} diff --git a/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java new file mode 100644 index 0000000..7175014 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java @@ -0,0 +1,341 @@ +package com.yourname.sellplugin.listener; + +import com.comphenix.protocol.PacketType; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.ProtocolManager; +import com.comphenix.protocol.events.ListenerPriority; +import com.comphenix.protocol.events.PacketAdapter; +import com.comphenix.protocol.events.PacketEvent; +import com.comphenix.protocol.events.PacketListener; +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.gui.CategoryItemsGUI; +import com.yourname.sellplugin.gui.CategoryProgressGUI; +import com.yourname.sellplugin.gui.ConfirmSellAllGUI; +import com.yourname.sellplugin.gui.ConfirmSellGUI; +import com.yourname.sellplugin.gui.SellAllGUI; +import com.yourname.sellplugin.gui.ShopMainGUI; +import com.yourname.sellplugin.gui.TopSellGUI; +import com.yourname.sellplugin.util.NumberFormatter; +import org.bukkit.GameMode; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.BlockInventoryHolder; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.List; + +public class WorthPacketListener { + + // Invisible marker prefixed to the worth line we inject. It is built from + // valid formatting codes only, so it renders no glyphs, but lets us reliably + // recognise (and strip) our own line — both to avoid duplicates and to keep + // it out of items the client sends back to the server (creative mode). + private static final char SECTION = '\u00A7'; + private static final String WORTH_MARKER = + "" + SECTION + '9' + SECTION + '8' + SECTION + '9' + SECTION + '8' + SECTION + 'r'; + + private final SellPlugin plugin; + private PacketListener packetListener; + private PacketListener creativeListener; + + public WorthPacketListener(SellPlugin plugin) { + this.plugin = plugin; + } + + public void register() { + if (plugin.getServer().getPluginManager().getPlugin("ProtocolLib") == null) { + plugin.getLogger().warning("ProtocolLib not found; sell worth tooltips are disabled."); + return; + } + + ProtocolManager protocolManager = ProtocolLibrary.getProtocolManager(); + packetListener = new PacketAdapter(plugin, ListenerPriority.NORMAL, + PacketType.Play.Server.SET_SLOT, + PacketType.Play.Server.WINDOW_ITEMS) { + + @Override + public void onPacketSending(PacketEvent event) { + Player viewer = event.getPlayer(); + if (viewer == null) return; + if (!worthAllowedFor(viewer)) return; + + Inventory top = viewer.getOpenInventory().getTopInventory(); + InventoryHolder holder = top.getHolder(); + boolean pluginGui = isPluginGui(holder); + int topSize = top.getSize(); + + // Window id: 0 = the player's own inventory, negative = cursor / + // direct player-slot set, positive = an open container window. + int windowId = 0; + boolean windowKnown = false; + try { + windowId = event.getPacket().getIntegers().read(0); + windowKnown = true; + } catch (Exception ignored) {} + boolean ownInventory = windowKnown && windowId <= 0; + + if (event.getPacketType() == PacketType.Play.Server.SET_SLOT) { + if (event.getPacket().getItemModifier().size() <= 0) return; + + boolean decorate; + if (ownInventory) { + // Player's own inventory / cursor item: always follow it, + // even while a custom GUI is open, so a held item keeps its + // worth line. + decorate = true; + } else if (pluginGui) { + // Only the mirrored player-inventory portion of a plugin + // GUI gets decorated; the GUI's own slots never do. + int slot = readSetSlotIndex(event); + decorate = slot >= topSize; + } else { + decorate = shouldDecorate(viewer); + } + if (!decorate) return; + + ItemStack item = event.getPacket().getItemModifier().read(0); + ItemStack updated = addWorthLore(viewer, item); + if (updated != item) { + event.getPacket().getItemModifier().write(0, updated); + } + return; + } + + if (event.getPacket().getItemListModifier().size() <= 0) return; + List items = event.getPacket().getItemListModifier().read(0); + if (items == null || items.isEmpty()) return; + + // Index of the first slot in this packet that belongs to the + // player's own inventory (everything from here on is decorated). + int playerStart; + if (ownInventory) { + playerStart = 0; + } else if (pluginGui) { + playerStart = topSize; + } else if (shouldDecorate(viewer)) { + playerStart = 0; + } else { + return; + } + + boolean changed = false; + List updatedItems = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + ItemStack item = items.get(i); + if (i >= playerStart) { + ItemStack updated = addWorthLore(viewer, item); + updatedItems.add(updated); + changed |= updated != item; + } else { + updatedItems.add(item); + } + } + + if (changed) { + event.getPacket().getItemListModifier().write(0, updatedItems); + } + } + }; + protocolManager.addPacketListener(packetListener); + + // Creative-mode clients echo the items they see back to the server. Strip + // our injected worth line from those inbound items so it never gets baked + // into the real ItemStack (which would otherwise produce duplicate lines). + creativeListener = new PacketAdapter(plugin, ListenerPriority.NORMAL, + PacketType.Play.Client.SET_CREATIVE_SLOT) { + + @Override + public void onPacketReceiving(PacketEvent event) { + if (event.getPacket().getItemModifier().size() <= 0) return; + ItemStack item = event.getPacket().getItemModifier().read(0); + ItemStack cleaned = stripWorthLore(item); + if (cleaned != item) { + event.getPacket().getItemModifier().write(0, cleaned); + } + } + }; + protocolManager.addPacketListener(creativeListener); + } + + public void unregister() { + ProtocolManager protocolManager = ProtocolLibrary.getProtocolManager(); + if (packetListener != null) { + protocolManager.removePacketListener(packetListener); + packetListener = null; + } + if (creativeListener != null) { + protocolManager.removePacketListener(creativeListener); + creativeListener = null; + } + } + + private ItemStack addWorthLore(Player player, ItemStack original) { + if (original == null || original.getType().isAir()) return original; + + double worth = plugin.getSellManager().calculateItemWorth(player, original); + + ItemMeta meta = original.getItemMeta(); + boolean hadWorthLine = meta != null && meta.hasLore() && loreHasWorthLine(meta.getLore()); + + // Nothing to add and nothing stale to clean up -> leave the item untouched. + if (worth <= 0 && !hadWorthLine) return original; + + ItemStack clone = original.clone(); + meta = clone.getItemMeta(); + if (meta == null) return original; + + // Always start from lore without any previously injected/baked worth line + // so we never stack duplicates. + List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); + removeWorthLines(lore); + + if (worth > 0) { + if (!lore.isEmpty() && !isBlank(lore.get(lore.size() - 1))) { + lore.add(""); + } + lore.add(WORTH_MARKER + plugin.getConfigManager().getWorthFormat() + .replace("{worth}", NumberFormatter.format(worth))); + } + + meta.setLore(lore.isEmpty() ? null : lore); + clone.setItemMeta(meta); + return clone; + } + + /** + * Returns a copy of {@code item} with any injected worth line removed, or the + * original reference if it carried none. + */ + private ItemStack stripWorthLore(ItemStack item) { + if (item == null || item.getType().isAir()) return item; + + ItemMeta meta = item.getItemMeta(); + if (meta == null || !meta.hasLore()) return item; + + List lore = new ArrayList<>(meta.getLore()); + if (!removeWorthLines(lore)) return item; + + ItemStack clone = item.clone(); + ItemMeta cloneMeta = clone.getItemMeta(); + cloneMeta.setLore(lore.isEmpty() ? null : lore); + clone.setItemMeta(cloneMeta); + return clone; + } + + private boolean loreHasWorthLine(List lore) { + for (String line : lore) { + if (line != null && line.contains(WORTH_MARKER)) return true; + } + return false; + } + + /** + * Removes every injected worth line (and the blank separator we place before + * it) from {@code lore} in place. Returns {@code true} if anything changed. + */ + private boolean removeWorthLines(List lore) { + boolean changed = false; + for (int i = lore.size() - 1; i >= 0; i--) { + String line = lore.get(i); + // Use contains() rather than startsWith(): when a creative client + // echoes our lore back it can arrive with an extra leading colour + // code (e.g. "§f") prepended, which would defeat a prefix match and + // let the line bake in / duplicate. + if (line == null || !line.contains(WORTH_MARKER)) continue; + lore.remove(i); + changed = true; + // Drop the blank separator we added directly before the worth line. + if (i - 1 >= 0 && isBlank(lore.get(i - 1))) { + lore.remove(i - 1); + } + } + return changed; + } + + /** + * Treats a line as blank if, after stripping any formatting codes, nothing + * printable remains. Round-tripped separators can come back as "§f" etc. + */ + private boolean isBlank(String line) { + if (line == null || line.isEmpty()) return true; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == SECTION) { + i++; // skip the code character that follows the section sign + continue; + } + if (!Character.isWhitespace(c)) return false; + } + return true; + } + + /** + * Whether the worth line may be injected for this viewer at all. Respects the + * per-player visibility toggle and hides the line from creative-mode players + * (whose clients echo lore back to the server, baking it into real items) + * unless explicitly enabled via {@code worth.show-in-creative}. + */ + private boolean worthAllowedFor(Player viewer) { + if (!plugin.getConfigManager().isWorthEnabled()) return false; + if (!plugin.getWorthVisibilityManager().isVisible(viewer.getUniqueId())) return false; + if (viewer.getGameMode() == GameMode.CREATIVE + && !plugin.getConfigManager().isWorthShownInCreative()) return false; + return true; + } + + /** Reads the slot index from a SET_SLOT packet, or -1 if it can't be read. */ + private int readSetSlotIndex(PacketEvent event) { + try { + if (event.getPacket().getShorts().size() > 0) { + return event.getPacket().getShorts().read(0); + } + } catch (Exception ignored) {} + try { + // Some mappings expose the slot as the 3rd integer (windowId, stateId, slot). + if (event.getPacket().getIntegers().size() > 2) { + return event.getPacket().getIntegers().read(2); + } + } catch (Exception ignored) {} + return -1; + } + + private boolean shouldDecorate(Player player) { + Inventory topInventory = player.getOpenInventory().getTopInventory(); + InventoryHolder holder = topInventory.getHolder(); + + if (isPluginGui(holder)) return false; + + InventoryType type = topInventory.getType(); + if (type == InventoryType.CRAFTING + || type == InventoryType.CREATIVE + || type == InventoryType.PLAYER) { + return true; + } + + if (holder instanceof BlockInventoryHolder || holder instanceof DoubleChest) { + return true; + } + + return switch (type) { + case ANVIL, BEACON, BLAST_FURNACE, BREWING, CARTOGRAPHY, CRAFTER, + ENCHANTING, FURNACE, GRINDSTONE, LOOM, MERCHANT, + SMITHING, SMOKER, STONECUTTER -> true; + default -> false; + }; + } + + private boolean isPluginGui(InventoryHolder holder) { + return holder instanceof ShopMainGUI + || holder instanceof CategoryProgressGUI + || holder instanceof CategoryItemsGUI + || holder instanceof SellAllGUI + || holder instanceof ConfirmSellGUI + || holder instanceof ConfirmSellAllGUI + || holder instanceof TopSellGUI; + } +} diff --git a/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java b/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java new file mode 100644 index 0000000..74ced43 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java @@ -0,0 +1,73 @@ +package com.yourname.sellplugin.listener; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.util.Scheduler; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.player.PlayerJoinEvent; + +/** + * The worth line is applied purely through outgoing item packets, so it only + * appears on items the client is (re)sent while a decoratable inventory is open. + * Items that arrive in other situations — picked up, bought from a shop GUI, + * handed over with /give while a menu is open — would otherwise stay bare until + * the next full inventory resend (i.e. a relog). + * + *

This listener nudges the client to redraw shortly after those moments by + * re-sending the player's inventory, which the packet listener then decorates. + */ +public class WorthRefreshListener implements Listener { + + private final SellPlugin plugin; + + public WorthRefreshListener(SellPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + if (event.getEntity() instanceof Player player) { + refresh(player); + } + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player) { + refresh(player); + } + } + + /** + * Picking an item onto the cursor and putting it back down are ordinary + * clicks; without a redraw the moved item (and the cursor item) come through + * bare. Re-sending the inventory the tick after any click keeps the worth + * line visible while items are held and replaced. + */ + @EventHandler + public void onClick(InventoryClickEvent event) { + if (event.getWhoClicked() instanceof Player player) { + refresh(player); + } + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + refresh(event.getPlayer()); + } + + private void refresh(Player player) { + if (!plugin.getConfigManager().isWorthEnabled()) return; + // Run next tick, on the player's own region thread (Folia-safe), so the + // inventory reflects the change that triggered us. + Scheduler.runEntityLater(plugin, player, () -> { + if (player.isOnline()) { + player.updateInventory(); + } + }, 1L); + } +} diff --git a/src/main/java/com/yourname/sellplugin/manager/ConfigManager.java b/src/main/java/com/yourname/sellplugin/manager/ConfigManager.java index 8fe1396..b40a8f6 100644 --- a/src/main/java/com/yourname/sellplugin/manager/ConfigManager.java +++ b/src/main/java/com/yourname/sellplugin/manager/ConfigManager.java @@ -2,7 +2,10 @@ import com.yourname.sellplugin.SellPlugin; import org.bukkit.ChatColor; +import org.bukkit.Material; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; public class ConfigManager { @@ -12,41 +15,274 @@ public ConfigManager(SellPlugin plugin) { this.plugin = plugin; } - public double getMultiplierStep() { - return plugin.getConfig().getDouble("multiplier-step", 0.001); + // ---- Feature toggles ----------------------------------------------------- + /** + * Generic feature switch under the {@code features.} section. Every + * major piece of the plugin can be turned off here without touching code. + */ + public boolean isFeatureEnabled(String key, boolean def) { + return plugin.getConfig().getBoolean("features." + key, def); } + public boolean isMultipliersEnabled() { return isFeatureEnabled("multipliers", true); } + public boolean isEnchantmentPricingEnabled(){ return isFeatureEnabled("enchantment-pricing", true); } + public boolean isShulkerSellingEnabled() { return isFeatureEnabled("shulker-selling", true); } + public boolean isProgressGuiEnabled() { return isFeatureEnabled("progress-gui", true); } + public boolean isTopSellEnabled() { return isFeatureEnabled("top-sell", true); } + public boolean isActionBarEnabled() { return isFeatureEnabled("action-bar", true); } + + // ---- Enchantment pricing ------------------------------------------------- + /** Base value added per enchantment level for enchantments not explicitly listed. */ + public double getEnchantDefaultValuePerLevel() { + return plugin.getConfig().getDouble("enchantments.default-value-per-level", 50.0); + } + + /** Factor the price is multiplied by for each distinct enchantment on the item. */ + public double getEnchantMultiplierPerEnchantment() { + return plugin.getConfig().getDouble("enchantments.multiplier-per-enchantment", 1.1); + } + + /** + * Value added per level for a specific enchantment key (e.g. "sharpness"), + * falling back to {@link #getEnchantDefaultValuePerLevel()} when unlisted. + */ + public double getEnchantValue(String enchantKey) { + return plugin.getConfig().getDouble("enchantments.values." + enchantKey, + getEnchantDefaultValuePerLevel()); + } + + // ---- Multiplier ------------------------------------------------------- + /** Cost (in money earned) to unlock the very first multiplier level (1.1x). */ + public double getStartMultiplier() { + return plugin.getConfig().getDouble("start-multiplier", 1000.0); + } + + /** + * Geometric factor: each subsequent milestone costs + * (previous milestone cost × this value). + */ + public double getMultiplierFactor() { + return plugin.getConfig().getDouble("multiplier", 1.6); + } + + public double getMaxMultiplier() { + return plugin.getConfig().getDouble("max-multiplier", 3.0); + } + + // ---- Progress bar colours --------------------------------------------- + public Material getProgressBarCompletedColor() { + return resolvePane(plugin.getConfig().getString( + "progress-bar.completed-color", "LIME_STAINED_GLASS_PANE"), + Material.LIME_STAINED_GLASS_PANE); + } + + public Material getProgressBarInProgressColor() { + return resolvePane(plugin.getConfig().getString( + "progress-bar.inprogress-color", "YELLOW_STAINED_GLASS_PANE"), + Material.YELLOW_STAINED_GLASS_PANE); + } + + public Material getProgressBarLockedColor() { + return resolvePane(plugin.getConfig().getString( + "progress-bar.locked-color", "GRAY_STAINED_GLASS_PANE"), + Material.GRAY_STAINED_GLASS_PANE); + } + + private Material resolvePane(String name, Material fallback) { + if (name == null) return fallback; + Material mat = Material.matchMaterial(name); + return mat != null ? mat : fallback; + } + + // ---- GUI (main shop menu) --------------------------------------------- public String getGuiTitle() { - return color(plugin.getConfig().getString("gui.title", "&8Sell Menu")); + return color(plugin.getConfig().getString("gui.title", "&8&lShop")); } public int getGuiSize() { - return plugin.getConfig().getInt("gui.size", 27); + return plugin.getConfig().getInt("gui.size", 45); + } + + // ---- Filler block ----------------------------------------------------- + public Material getFillerBlock() { + String matName = plugin.getConfig().getString("filler-block", "BLACK_STAINED_GLASS_PANE"); + Material mat = Material.matchMaterial(matName); + return mat != null ? mat : Material.BLACK_STAINED_GLASS_PANE; + } + + public boolean isWorthEnabled() { + return plugin.getConfig().getBoolean("worth.enabled", true); + } + + /** + * Whether to inject the worth line for players in creative mode. Creative + * clients echo whatever lore they are shown back to the server, which bakes + * the line into the real item and causes duplicates — so this defaults to + * false. Only turn it on if you understand that trade-off. + */ + public boolean isWorthShownInCreative() { + return plugin.getConfig().getBoolean("worth.show-in-creative", false); + } + + public String getWorthFormat() { + return color(plugin.getConfig().getString("worth.format", "&7Worth &a&l${worth}")); + } + + /** Config schema version, used by the auto-migrator. 0 = pre-versioning. */ + public int getConfigVersion() { + return plugin.getConfig().getInt("config-version", 0); + } + + // ---- SellAll GUI (simple /sellall GUI) -------------------------------- + public String getSellAllGuiTitle() { + return color(plugin.getConfig().getString("sell-all-gui.title", "&8&lSell All Items")); + } + + public int getSellAllGuiSize() { + return plugin.getConfig().getInt("sell-all-gui.size", 27); } public int getSellAllSlot() { - return plugin.getConfig().getInt("gui.sell-all-slot", 13); + return plugin.getConfig().getInt("sell-all-gui.slot", 13); } public String getSellAllMaterial() { - return plugin.getConfig().getString("gui.sell-all-item", "EMERALD_BLOCK"); + return plugin.getConfig().getString("sell-all-gui.item", "EMERALD_BLOCK"); } public String getSellAllName() { - return color(plugin.getConfig().getString("gui.sell-all-name", "&aSell All")); + return color(plugin.getConfig().getString("sell-all-gui.name", "&a&lSell All")); } public List getSellAllLore() { - return plugin.getConfig().getStringList("gui.sell-all-lore"); + return plugin.getConfig().getStringList("sell-all-gui.lore"); + } + + // ---- Prefix / sounds -------------------------------------------------- + public boolean isPrefixEnabled() { + return plugin.getConfig().getBoolean("prefix-enabled", false); + } + + public boolean areSoundsEnabled() { + return plugin.getConfig().getBoolean("sounds-enabled", true); + } + + public String getSoundType() { + return plugin.getConfig().getString("sound-type", "ENTITY_EXPERIENCE_ORB_PICKUP"); + } + + public boolean isTitleNotificationEnabled() { + return plugin.getConfig().getBoolean("title-notification-enabled", false); + } + + // ---- Economy ---------------------------------------------------------- + public String getEconomyMode() { + return plugin.getConfig().getString("economy-mode", "VAULT").toUpperCase(); + } + + public String getCoinsEngineCurrencyId() { + return plugin.getConfig().getString("coinsengine-currency-id", "coins"); + } + + // ---- Category config -------------------------------------------------- + public List getCategoryOrder() { + List order = plugin.getConfig().getStringList("category-order"); + if (order == null || order.isEmpty()) { + List sorted = new ArrayList<>(plugin.getPriceManager().getCategories()); + Collections.sort(sorted); + return sorted; + } + return order; + } + + public String getCategoryDisplayName(String categoryId) { + String path = "categories." + categoryId + ".display-name"; + String def = "&f" + capitalize(categoryId); + return color(plugin.getConfig().getString(path, def)); } + public Material getCategoryMaterial(String categoryId) { + String path = "categories." + categoryId + ".material"; + String matName = plugin.getConfig().getString(path, "CHEST"); + Material mat = Material.matchMaterial(matName); + return mat != null ? mat : Material.CHEST; + } + + public List getCategoryLore(String categoryId) { + List raw = plugin.getConfig().getStringList("categories." + categoryId + ".lore"); + List result = new ArrayList<>(); + for (String line : raw) result.add(color(line)); + return result; + } + + // ---- Messages --------------------------------------------------------- public String getMessage(String path) { - String prefix = plugin.getConfig().getString("messages.prefix", ""); + String prefix = isPrefixEnabled() + ? color(plugin.getConfig().getString("messages.prefix", "")) + : ""; String msg = plugin.getConfig().getString("messages." + path, ""); return color(prefix + msg); } + /** + * Returns an arbitrary configurable text under {@code messages.}, + * colour-translated, falling back to {@code def} if absent. + * Used for all customisable GUI/command text that isn't a chat "message". + */ + public String getText(String path, String def) { + return color(plugin.getConfig().getString("messages." + path, def)); + } + + // ---- Icons ---------------------------------------------------------------- + /** + * Returns the configured Material for an icon key, falling back to + * {@code fallback} if the key is absent or the material name is invalid. + * + * @param key e.g. "back", "confirm", "prev-page" + * @param fallback default Material to use + */ + public Material getIconMaterial(String key, Material fallback) { + String matName = plugin.getConfig().getString("icons." + key + ".material"); + if (matName == null) return fallback; + Material mat = Material.matchMaterial(matName); + return mat != null ? mat : fallback; + } + + /** + * Returns the colour-translated display name for an icon key. + * Falls back to {@code defaultName} (also colour-translated) if absent. + */ + public String getIconName(String key, String defaultName) { + String name = plugin.getConfig().getString("icons." + key + ".name"); + return color(name != null ? name : defaultName); + } + + /** + * Returns the colour-translated lore lines for an icon key. + * Falls back to {@code defaultLore} (pre-coloured) if the list is empty. + */ + public List getIconLore(String key, List defaultLore) { + List raw = plugin.getConfig().getStringList("icons." + key + ".lore"); + if (raw.isEmpty()) return defaultLore; + List result = new ArrayList<>(); + for (String line : raw) result.add(color(line)); + return result; + } + + // ---- Reload ----------------------------------------------------------- + public void reload() { + plugin.reloadConfig(); + plugin.getPriceManager().loadPrices(); + } + + // ---- Helpers ---------------------------------------------------------- public String color(String s) { + if (s == null) return ""; return ChatColor.translateAlternateColorCodes('&', s); } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(); + } } diff --git a/src/main/java/com/yourname/sellplugin/manager/ConfigMigrator.java b/src/main/java/com/yourname/sellplugin/manager/ConfigMigrator.java new file mode 100644 index 0000000..6248502 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/manager/ConfigMigrator.java @@ -0,0 +1,103 @@ +package com.yourname.sellplugin.manager; + +import com.yourname.sellplugin.SellPlugin; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +/** + * Automatically brings an existing {@code config.yml} up to date whenever the + * bundled schema version is newer than the one on disk (or the file has no + * version at all, which is treated as "very old"). + * + *

Before changing anything it writes a timestamped backup next to the config + * so nothing is ever lost. Migration then: + *

    + *
  • adds any options the user is missing (copied from the bundled defaults),
  • + *
  • removes options that no longer exist, and
  • + *
  • stamps the current schema version.
  • + *
+ * + *

Note: rewriting the file through Bukkit strips hand-written comments. The + * pre-migration backup keeps the original (comments and all) intact. + */ +public class ConfigMigrator { + + /** Bump this whenever the bundled config.yml gains or drops options. */ + public static final int CURRENT_VERSION = 1; + + /** Keys that used to exist but have been removed from the plugin. */ + private static final List OBSOLETE_KEYS = List.of( + "daily-bonus" + ); + + private final SellPlugin plugin; + + public ConfigMigrator(SellPlugin plugin) { + this.plugin = plugin; + } + + /** Runs migration if needed. Safe to call every startup. */ + public void migrate() { + File configFile = new File(plugin.getDataFolder(), "config.yml"); + // saveDefaultConfig() runs before us, so a brand-new install already has + // the current file – nothing to migrate. + if (!configFile.exists()) return; + + FileConfiguration config = plugin.getConfig(); + int version = config.getInt("config-version", 0); + if (version >= CURRENT_VERSION) return; + + plugin.getLogger().info("Old config detected (version " + version + + "); migrating to version " + CURRENT_VERSION + "."); + + backup(configFile, version); + + // Merge any missing options from the bundled defaults. + try (InputStream defStream = plugin.getResource("config.yml")) { + if (defStream != null) { + YamlConfiguration defaults = YamlConfiguration.loadConfiguration( + new InputStreamReader(defStream, StandardCharsets.UTF_8)); + config.setDefaults(defaults); + config.options().copyDefaults(true); + } + } catch (IOException e) { + plugin.getLogger().warning("Could not read bundled config defaults: " + e.getMessage()); + } + + // Drop options that no longer exist. + for (String key : OBSOLETE_KEYS) { + config.set(key, null); + } + + // Stamp the new version and persist. + config.set("config-version", CURRENT_VERSION); + plugin.saveConfig(); + + plugin.getLogger().info("Config migration complete. A backup of your old " + + "config was saved in the plugin folder."); + } + + private void backup(File configFile, int version) { + try { + String stamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()); + File backup = new File(plugin.getDataFolder(), + "config-backup-v" + version + "-" + stamp + ".yml"); + Files.copy(configFile.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING); + plugin.getLogger().info("Backed up existing config to " + backup.getName() + "."); + } catch (IOException e) { + plugin.getLogger().warning("Could not back up config.yml before migrating: " + + e.getMessage()); + } + } +} diff --git a/src/main/java/com/yourname/sellplugin/manager/MultiplierManager.java b/src/main/java/com/yourname/sellplugin/manager/MultiplierManager.java index eb6287b..45b8aed 100644 --- a/src/main/java/com/yourname/sellplugin/manager/MultiplierManager.java +++ b/src/main/java/com/yourname/sellplugin/manager/MultiplierManager.java @@ -1,21 +1,26 @@ package com.yourname.sellplugin.manager; import com.yourname.sellplugin.SellPlugin; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import java.io.File; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; +import java.util.*; public class MultiplierManager { private final SellPlugin plugin; private final File dataFolder; - - // UUID -> (Category -> Items Sold) - private final Map> cache = new HashMap<>(); + + // UUID -> (Category -> Money Earned) + private final Map> cache = new HashMap<>(); + + // Leaderboard cache + private List leaderboardCache = null; + private long leaderboardCacheTime = 0; + private static final long LEADERBOARD_CACHE_TTL = 30_000L; public MultiplierManager(SellPlugin plugin) { this.plugin = plugin; @@ -27,13 +32,13 @@ public MultiplierManager(SellPlugin plugin) { public void loadPlayer(UUID uuid) { File file = new File(dataFolder, uuid.toString() + ".yml"); - Map stats = new HashMap<>(); - + Map stats = new HashMap<>(); + if (file.exists()) { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); if (config.contains("stats")) { for (String category : config.getConfigurationSection("stats").getKeys(false)) { - stats.put(category, config.getInt("stats." + category)); + stats.put(category, config.getDouble("stats." + category)); } } } @@ -41,21 +46,29 @@ public void loadPlayer(UUID uuid) { } public void savePlayer(UUID uuid) { - Map stats = cache.get(uuid); + Map stats = cache.get(uuid); if (stats == null || stats.isEmpty()) return; File file = new File(dataFolder, uuid.toString() + ".yml"); YamlConfiguration config = new YamlConfiguration(); - - for (Map.Entry entry : stats.entrySet()) { + + for (Map.Entry entry : stats.entrySet()) { config.set("stats." + entry.getKey(), entry.getValue()); } + // Persist player name for the leaderboard + OfflinePlayer op = Bukkit.getOfflinePlayer(uuid); + if (op.getName() != null) { + config.set("name", op.getName()); + } + try { config.save(file); } catch (IOException e) { plugin.getLogger().severe("Failed to save data for " + uuid.toString()); } + + leaderboardCache = null; // invalidate leaderboard cache } public void saveAll() { @@ -64,25 +77,162 @@ public void saveAll() { } } + // ----------------------------------------------------------------------- + // Multiplier – geometric progression + // + // cost[0] = startMultiplier (to reach 1.1x) + // cost[i] = startMultiplier * factor^i + // cumulative[level] = sum of cost[0..level-1] + // + // The returned value is stepped: 1.0, 1.1, 1.2, … up to maxMultiplier. + // ----------------------------------------------------------------------- public double getMultiplier(Player p, String category) { + if (!plugin.getConfigManager().isMultipliersEnabled()) return 1.0; if (!cache.containsKey(p.getUniqueId())) loadPlayer(p.getUniqueId()); - - Map stats = cache.get(p.getUniqueId()); - int itemsSold = stats.getOrDefault(category, 0); - - double step = plugin.getConfigManager().getMultiplierStep(); - return 1.0 + (itemsSold * step); + + double moneyEarned = cache.get(p.getUniqueId()).getOrDefault(category, 0.0); + + double start = plugin.getConfigManager().getStartMultiplier(); + double factor = plugin.getConfigManager().getMultiplierFactor(); + double max = plugin.getConfigManager().getMaxMultiplier(); + + int maxLevel = (int) Math.round((max - 1.0) / 0.1); + int level = 0; + double cumulative = 0.0; + double cost = start; + + while (level < maxLevel) { + cumulative += cost; + if (moneyEarned < cumulative) break; + level++; + cost *= factor; + } + + return 1.0 + level * 0.1; + } + + /** + * Returns the player's effective multiplier for a category. Kept as a + * distinct method (rather than inlining {@link #getMultiplier}) so callers + * don't need to change; the daily-bonus component was removed. + */ + public double getEffectiveMultiplier(Player p, String category) { + return getMultiplier(p, category); + } + + /** + * Returns the cumulative money required to reach milestone {@code milestoneIndex} + * (0-based). Index 0 = 1.0x (no cost). Index 1 = 1.1x (costs startMultiplier). + */ + public double getCumulativeThreshold(int milestoneIndex) { + if (milestoneIndex <= 0) return 0.0; + double start = plugin.getConfigManager().getStartMultiplier(); + double factor = plugin.getConfigManager().getMultiplierFactor(); + if (Math.abs(factor - 1.0) < 0.0001) { + return start * milestoneIndex; + } + return start * (Math.pow(factor, milestoneIndex) - 1.0) / (factor - 1.0); } - public void addSales(Player p, String category, int amount) { + public void addEarnings(Player p, String category, double amount) { if (!cache.containsKey(p.getUniqueId())) loadPlayer(p.getUniqueId()); - - Map stats = cache.get(p.getUniqueId()); - stats.put(category, stats.getOrDefault(category, 0) + amount); + + Map stats = cache.get(p.getUniqueId()); + stats.put(category, stats.getOrDefault(category, 0.0) + amount); + + leaderboardCache = null; // invalidate leaderboard cache } - - public Map getStats(Player p) { + + public Map getStats(Player p) { if (!cache.containsKey(p.getUniqueId())) loadPlayer(p.getUniqueId()); return cache.get(p.getUniqueId()); } + + /** Returns the total money earned in a specific category. */ + public double getMoneyEarned(Player p, String category) { + if (!cache.containsKey(p.getUniqueId())) loadPlayer(p.getUniqueId()); + return cache.get(p.getUniqueId()).getOrDefault(category, 0.0); + } + + // ----------------------------------------------------------------------- + // Leaderboard + // ----------------------------------------------------------------------- + + public List getLeaderboard() { + long now = System.currentTimeMillis(); + if (leaderboardCache != null && (now - leaderboardCacheTime) < LEADERBOARD_CACHE_TTL) { + return leaderboardCache; + } + leaderboardCache = buildLeaderboard(); + leaderboardCacheTime = now; + return leaderboardCache; + } + + private List buildLeaderboard() { + Map totals = new HashMap<>(); + Map names = new HashMap<>(); + + // Add online/cached players first + for (Map.Entry> e : cache.entrySet()) { + double total = e.getValue().values().stream().mapToDouble(Double::doubleValue).sum(); + totals.put(e.getKey(), total); + OfflinePlayer op = Bukkit.getOfflinePlayer(e.getKey()); + if (op.getName() != null) names.put(e.getKey(), op.getName()); + } + + // Read remaining player files from disk + if (dataFolder.exists()) { + File[] files = dataFolder.listFiles((d, n) -> n.endsWith(".yml")); + if (files != null) { + for (File file : files) { + try { + UUID uuid = UUID.fromString(file.getName().replace(".yml", "")); + if (totals.containsKey(uuid)) continue; + + YamlConfiguration cfg = YamlConfiguration.loadConfiguration(file); + double total = 0.0; + if (cfg.contains("stats")) { + for (String cat : cfg.getConfigurationSection("stats").getKeys(false)) { + total += cfg.getDouble("stats." + cat); + } + } + totals.put(uuid, total); + + String name = cfg.getString("name"); + if (name == null) { + OfflinePlayer op = Bukkit.getOfflinePlayer(uuid); + name = op.getName(); + } + if (name != null) names.put(uuid, name); + } catch (IllegalArgumentException ignored) { + // file name isn't a valid UUID – skip + } + } + } + } + + List entries = new ArrayList<>(); + for (Map.Entry e : totals.entrySet()) { + String name = names.getOrDefault(e.getKey(), "Unknown"); + entries.add(new LeaderboardEntry(e.getKey(), name, e.getValue())); + } + entries.sort((a, b) -> Double.compare(b.totalEarnings, a.totalEarnings)); + return entries; + } + + // ----------------------------------------------------------------------- + // Inner classes + // ----------------------------------------------------------------------- + + public static class LeaderboardEntry { + public final UUID uuid; + public final String name; + public final double totalEarnings; + + public LeaderboardEntry(UUID uuid, String name, double totalEarnings) { + this.uuid = uuid; + this.name = name; + this.totalEarnings = totalEarnings; + } + } } diff --git a/src/main/java/com/yourname/sellplugin/manager/PriceManager.java b/src/main/java/com/yourname/sellplugin/manager/PriceManager.java index 71083a4..2912f7f 100644 --- a/src/main/java/com/yourname/sellplugin/manager/PriceManager.java +++ b/src/main/java/com/yourname/sellplugin/manager/PriceManager.java @@ -82,4 +82,9 @@ public String getCategory(String itemKey) { public Set getCategories() { return categories; } + + /** Returns all loaded item keys (e.g. "DIAMOND", "LINGERING_POTION:NIGHT_VISION"). */ + public Set getAllItemKeys() { + return prices.keySet(); + } } diff --git a/src/main/java/com/yourname/sellplugin/manager/SellManager.java b/src/main/java/com/yourname/sellplugin/manager/SellManager.java new file mode 100644 index 0000000..9c18e92 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/manager/SellManager.java @@ -0,0 +1,498 @@ +package com.yourname.sellplugin.manager; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.util.NumberFormatter; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.ShulkerBox; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.inventory.meta.EnchantmentStorageMeta; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class SellManager { + + private static final String FALLBACK_SOUND = "ENTITY_EXPERIENCE_ORB_PICKUP"; + + private final SellPlugin plugin; + + public SellManager(SellPlugin plugin) { + this.plugin = plugin; + } + + // --------------------------------------------------------------- + // Sell entire player inventory + // --------------------------------------------------------------- + public SellResult sellAll(Player player) { + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + + int storageSize = player.getInventory().getStorageContents().length; + for (int i = 0; i < storageSize; i++) { + ItemStack item = player.getInventory().getItem(i); + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + ShulkerSellData data = sellShulkerContents(player, item, null, null); + totalEarned += data.earned; + totalItems += data.items; + data.categoryEarnings.forEach((cat, val) -> categoryEarnings.merge(cat, val, Double::sum)); + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) continue; + + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + + String cat = plugin.getPriceManager().getCategory(key); + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = item.getAmount(); + double earned = enchantedUnitPrice(item, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + player.getInventory().setItem(i, null); + } + + return finalizeSell(player, totalEarned, totalItems, categoryEarnings); + } + + // --------------------------------------------------------------- + // Sell only items in a specific category + // --------------------------------------------------------------- + public SellResult sellCategory(Player player, String category) { + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + + int storageSize = player.getInventory().getStorageContents().length; + for (int i = 0; i < storageSize; i++) { + ItemStack item = player.getInventory().getItem(i); + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + ShulkerSellData data = sellShulkerContents(player, item, category, null); + totalEarned += data.earned; + totalItems += data.items; + data.categoryEarnings.forEach((cat, val) -> categoryEarnings.merge(cat, val, Double::sum)); + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) continue; + + String cat = plugin.getPriceManager().getCategory(key); + if (!category.equalsIgnoreCase(cat)) continue; + + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = item.getAmount(); + double earned = enchantedUnitPrice(item, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + player.getInventory().setItem(i, null); + } + + return finalizeSell(player, totalEarned, totalItems, categoryEarnings); + } + + // --------------------------------------------------------------- + // Sell all stacks of a specific item type/key + // --------------------------------------------------------------- + public SellResult sellItemType(Player player, String itemKey) { + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + + double base = plugin.getPriceManager().getPrice(itemKey); + if (base <= 0) return new SellResult(0, 0, false); + + String cat = plugin.getPriceManager().getCategory(itemKey); + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + + int storageSize = player.getInventory().getStorageContents().length; + for (int i = 0; i < storageSize; i++) { + ItemStack item = player.getInventory().getItem(i); + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + ShulkerSellData data = sellShulkerContents(player, item, null, itemKey); + totalEarned += data.earned; + totalItems += data.items; + data.categoryEarnings.forEach((c, val) -> categoryEarnings.merge(c, val, Double::sum)); + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (!itemKey.equalsIgnoreCase(key)) continue; + + int amount = item.getAmount(); + double earned = enchantedUnitPrice(item, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + player.getInventory().setItem(i, null); + } + + return finalizeSell(player, totalEarned, totalItems, categoryEarnings); + } + + // --------------------------------------------------------------- + // Preview result (item count + value) for selling all items + // --------------------------------------------------------------- + public SellPreview previewSellAll(Player player) { + int itemCount = 0; + double value = 0.0; + for (ItemStack item : player.getInventory().getStorageContents()) { + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + ShulkerSellData data = peekShulkerContents(player, item, null, null); + itemCount += data.items; + value += data.earned; + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) continue; + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + String cat = plugin.getPriceManager().getCategory(key); + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + itemCount += item.getAmount(); + value += enchantedUnitPrice(item, base) * mult * item.getAmount(); + } + return new SellPreview(itemCount, value); + } + + // --------------------------------------------------------------- + // Count how many sellable items of a category the player has + // --------------------------------------------------------------- + public int countCategoryItems(Player player, String category) { + int total = 0; + for (ItemStack item : player.getInventory().getStorageContents()) { + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + total += peekShulkerContents(player, item, category, null).items; + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) continue; + String cat = plugin.getPriceManager().getCategory(key); + if (category.equalsIgnoreCase(cat)) total += item.getAmount(); + } + return total; + } + + // --------------------------------------------------------------- + // Calculate value of sellable items in a category (with multiplier) + // --------------------------------------------------------------- + public double calculateCategoryValue(Player player, String category) { + // The multiplier depends only on the category, which is fixed here, so + // look it up once instead of once per matching item. + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, category); + double total = 0.0; + for (ItemStack item : player.getInventory().getStorageContents()) { + if (item == null || item.getType() == Material.AIR) continue; + + if (sellShulker(item)) { + total += peekShulkerContents(player, item, category, null).earned; + continue; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) continue; + String cat = plugin.getPriceManager().getCategory(key); + if (!category.equalsIgnoreCase(cat)) continue; + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + total += enchantedUnitPrice(item, base) * mult * item.getAmount(); + } + return total; + } + + public double calculateItemWorth(Player player, ItemStack item) { + if (item == null || item.getType() == Material.AIR) return 0.0; + + if (isShulkerBox(item)) { + return peekShulkerContents(player, item, null, null).earned; + } + + String key = plugin.getPriceManager().getItemKey(item); + if (key == null) return 0.0; + + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) return 0.0; + + String category = plugin.getPriceManager().getCategory(key); + double multiplier = plugin.getMultiplierManager().getEffectiveMultiplier(player, category); + return enchantedUnitPrice(item, base) * multiplier * item.getAmount(); + } + + // --------------------------------------------------------------- + // Finalize a sell operation + // --------------------------------------------------------------- + private SellResult finalizeSell(Player player, double totalEarned, int totalItems, + Map categoryEarnings) { + if (totalEarned <= 0) { + player.sendMessage(plugin.getConfigManager().getMessage("nothing-to-sell")); + return new SellResult(0, 0, false); + } + + boolean ok = plugin.getEconomyManager().deposit(player, totalEarned); + if (!ok) { + player.sendMessage(plugin.getConfigManager().getMessage("economy-error")); + return new SellResult(0, 0, false); + } + + for (Map.Entry e : categoryEarnings.entrySet()) { + plugin.getMultiplierManager().addEarnings(player, e.getKey(), e.getValue()); + } + + sendSellNotification(player, totalEarned, totalItems); + return new SellResult(totalEarned, totalItems, true); + } + + // --------------------------------------------------------------- + // Notification: action bar only (+$amount in lime/green color) + // Title and chat are optional via config + // --------------------------------------------------------------- + public void sendSellNotification(Player player, double amount, int itemCount) { + String formatted = NumberFormatter.format(amount); + + // Action bar: "+$amount" (toggleable) + if (plugin.getConfigManager().isActionBarEnabled()) { + String actionBarText = plugin.getConfigManager().getText("action-bar", "&a+${amount}") + .replace("{amount}", formatted); + player.sendActionBar(actionBarText); + } + + // Title notification: only if enabled in config + if (plugin.getConfigManager().isTitleNotificationEnabled()) { + String titleText = plugin.getConfigManager().getText("sell-title", "&a+${amount}") + .replace("{amount}", formatted); + String subtitleText = plugin.getConfigManager().getText("sell-subtitle", "&7You sold {count} item(s)") + .replace("{count}", NumberFormatter.format(itemCount)); + player.sendTitle(titleText, subtitleText, 10, 40, 20); + } + + // Play sound if enabled + String soundName = plugin.getConfigManager().getSoundType(); + if (plugin.getConfigManager().areSoundsEnabled() && soundName != null) { + try { + Sound sound = Sound.valueOf(soundName); + player.playSound(player.getLocation(), sound, 1.0f, 1.2f); + } catch (IllegalArgumentException ignored) { + player.playSound(player.getLocation(), Sound.valueOf(FALLBACK_SOUND), 1.0f, 1.2f); + } + } + + // Chat message: only if prefix enabled + if (plugin.getConfigManager().isPrefixEnabled()) { + String msg = plugin.getConfigManager().getMessage("sold-items") + .replace("{amount}", NumberFormatter.format(itemCount)) + .replace("{price}", formatted); + player.sendMessage(msg); + } + } + + // --------------------------------------------------------------- + // Shulker box helpers + // --------------------------------------------------------------- + + /** + * Returns true if the item is a shulker box of any colour, including the + * uncoloured {@code SHULKER_BOX} (whose name does not end with + * {@code _SHULKER_BOX}, which is why it used to be skipped). + */ + public static boolean isShulkerBox(ItemStack item) { + if (item == null) return false; + Material type = item.getType(); + return type == Material.SHULKER_BOX || type.name().endsWith("_SHULKER_BOX"); + } + + /** True when the item should be dived into and its contents sold. */ + private boolean sellShulker(ItemStack item) { + return plugin.getConfigManager().isShulkerSellingEnabled() && isShulkerBox(item); + } + + // --------------------------------------------------------------- + // Enchantment-aware unit pricing + // --------------------------------------------------------------- + + /** + * Adjusts a single item's base price for its enchantments: each + * enchantment's configured value (× its level) is added to the base, then + * the total is multiplied by a factor (default 1.1) for every distinct + * enchantment on the item. Unenchanted items return the base unchanged. + */ + public double enchantedUnitPrice(ItemStack item, double base) { + if (item == null || !plugin.getConfigManager().isEnchantmentPricingEnabled()) return base; + + Map enchants = collectEnchantments(item); + if (enchants.isEmpty()) return base; + + double added = 0.0; + int count = 0; + for (Map.Entry e : enchants.entrySet()) { + String key = e.getKey().getKey().getKey(); // e.g. "sharpness" + added += plugin.getConfigManager().getEnchantValue(key) * e.getValue(); + count++; + } + + double factor = plugin.getConfigManager().getEnchantMultiplierPerEnchantment(); + return (base + added) * Math.pow(factor, count); + } + + /** Merges an item's applied enchantments with any stored (book) enchantments. */ + private Map collectEnchantments(ItemStack item) { + Map merged = new HashMap<>(item.getEnchantments()); + ItemMeta meta = item.getItemMeta(); + if (meta instanceof EnchantmentStorageMeta storage) { + storage.getStoredEnchants().forEach((ench, lvl) -> merged.merge(ench, lvl, Math::max)); + } + return merged; + } + + /** + * Sell sellable items inside a shulker box, modifying its inventory in place. + * Filters by category and/or itemKey when non-null. + * The shulker box item itself is never consumed. + */ + public ShulkerSellData sellShulkerContents(Player player, ItemStack shulkerItem, + String categoryFilter, String itemKeyFilter) { + if (!(shulkerItem.getItemMeta() instanceof BlockStateMeta bsm)) return ShulkerSellData.EMPTY; + if (!(bsm.getBlockState() instanceof ShulkerBox shulker)) return ShulkerSellData.EMPTY; + + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + + ItemStack[] contents = shulker.getInventory().getContents(); + for (int i = 0; i < contents.length; i++) { + ItemStack inner = contents[i]; + if (inner == null || inner.getType() == Material.AIR) continue; + + String key = plugin.getPriceManager().getItemKey(inner); + if (key == null) continue; + + String cat = plugin.getPriceManager().getCategory(key); + if (categoryFilter != null && !categoryFilter.equalsIgnoreCase(cat)) continue; + if (itemKeyFilter != null && !itemKeyFilter.equalsIgnoreCase(key)) continue; + + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = inner.getAmount(); + double earned = enchantedUnitPrice(inner, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + shulker.getInventory().setItem(i, null); + } + + if (totalItems > 0) { + bsm.setBlockState(shulker); + shulkerItem.setItemMeta(bsm); + } + + return new ShulkerSellData(totalEarned, totalItems, categoryEarnings); + } + + /** + * Peek at sellable items inside a shulker box without modifying it. + * Filters by category and/or itemKey when non-null. + */ + private ShulkerSellData peekShulkerContents(Player player, ItemStack shulkerItem, + String categoryFilter, String itemKeyFilter) { + if (!(shulkerItem.getItemMeta() instanceof BlockStateMeta bsm)) return ShulkerSellData.EMPTY; + if (!(bsm.getBlockState() instanceof ShulkerBox shulker)) return ShulkerSellData.EMPTY; + + double totalEarned = 0.0; + int totalItems = 0; + Map categoryEarnings = new HashMap<>(); + + for (ItemStack inner : shulker.getInventory().getContents()) { + if (inner == null || inner.getType() == Material.AIR) continue; + + String key = plugin.getPriceManager().getItemKey(inner); + if (key == null) continue; + + String cat = plugin.getPriceManager().getCategory(key); + if (categoryFilter != null && !categoryFilter.equalsIgnoreCase(cat)) continue; + if (itemKeyFilter != null && !itemKeyFilter.equalsIgnoreCase(key)) continue; + + double base = plugin.getPriceManager().getPrice(key); + if (base <= 0) continue; + + double mult = plugin.getMultiplierManager().getEffectiveMultiplier(player, cat); + int amount = inner.getAmount(); + double earned = enchantedUnitPrice(inner, base) * mult * amount; + totalEarned += earned; + totalItems += amount; + categoryEarnings.merge(cat, earned, Double::sum); + } + + return new ShulkerSellData(totalEarned, totalItems, categoryEarnings); + } + + // --------------------------------------------------------------- + // Shulker sell data container + // --------------------------------------------------------------- + public static class ShulkerSellData { + public static final ShulkerSellData EMPTY = new ShulkerSellData(0, 0, Collections.emptyMap()); + + public final double earned; + public final int items; + public final Map categoryEarnings; + + public ShulkerSellData(double earned, int items, Map categoryEarnings) { + this.earned = earned; + this.items = items; + this.categoryEarnings = categoryEarnings; + } + } + + // --------------------------------------------------------------- + // Simple inner result class + // --------------------------------------------------------------- + public static class SellResult { + public final double earned; + public final int itemsSold; + public final boolean success; + + public SellResult(double earned, int itemsSold, boolean success) { + this.earned = earned; + this.itemsSold = itemsSold; + this.success = success; + } + } + + // --------------------------------------------------------------- + // Preview result for sell-all (no inventory modification) + // --------------------------------------------------------------- + public static class SellPreview { + public final int itemCount; + public final double value; + + public SellPreview(int itemCount, double value) { + this.itemCount = itemCount; + this.value = value; + } + } +} diff --git a/src/main/java/com/yourname/sellplugin/manager/WorthVisibilityManager.java b/src/main/java/com/yourname/sellplugin/manager/WorthVisibilityManager.java new file mode 100644 index 0000000..e3a27bb --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/manager/WorthVisibilityManager.java @@ -0,0 +1,107 @@ +package com.yourname.sellplugin.manager; + +import com.yourname.sellplugin.SellPlugin; +import com.yourname.sellplugin.util.Scheduler; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which players have chosen to hide the cosmetic worth tooltip + * ({@code /showworth}). Worth is shown by default (subject to the global + * {@code worth.enabled} config), so we only need to persist the set of players + * who have opted out. + * + *

Reads happen from the ProtocolLib packet thread, so the backing set is + * concurrent; writes to disk are pushed off-thread via the async scheduler so + * this is safe on Folia as well as Paper. + */ +public class WorthVisibilityManager { + + private final SellPlugin plugin; + private final File file; + + /** UUIDs of players who have hidden the worth tooltip. */ + private final Set hidden = ConcurrentHashMap.newKeySet(); + + public WorthVisibilityManager(SellPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "worth-visibility.yml"); + load(); + } + + private void load() { + if (!file.exists()) return; + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + for (String raw : config.getStringList("hidden")) { + try { + hidden.add(UUID.fromString(raw)); + } catch (IllegalArgumentException ignored) { + // skip malformed UUID entries + } + } + } + + /** Persists the current set to disk on the calling thread. */ + public void saveNow() { + YamlConfiguration config = new YamlConfiguration(); + List list = new ArrayList<>(hidden.size()); + for (UUID uuid : hidden) { + list.add(uuid.toString()); + } + config.set("hidden", list); + try { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + config.save(file); + } catch (IOException e) { + plugin.getLogger().severe("Failed to save worth-visibility.yml: " + e.getMessage()); + } + } + + private void saveAsync() { + Scheduler.runAsync(plugin, this::saveNow); + } + + /** @return {@code true} if the player has hidden the worth tooltip. */ + public boolean isHidden(UUID uuid) { + return hidden.contains(uuid); + } + + /** @return {@code true} if the worth tooltip should be shown to this player. */ + public boolean isVisible(UUID uuid) { + return plugin.getConfigManager().isWorthEnabled() && !hidden.contains(uuid); + } + + /** + * Sets whether the worth tooltip is shown for {@code uuid}. + * + * @return {@code true} if this changed the stored state. + */ + public boolean setVisible(UUID uuid, boolean visible) { + boolean changed = visible ? hidden.remove(uuid) : hidden.add(uuid); + if (changed) { + saveAsync(); + } + return changed; + } + + /** + * Flips the current preference for {@code uuid}. + * + * @return the new visibility state ({@code true} = now shown). + */ + public boolean toggle(UUID uuid) { + boolean nowVisible = hidden.contains(uuid); // was hidden -> becomes visible + setVisible(uuid, nowVisible); + return nowVisible; + } +} diff --git a/src/main/java/com/yourname/sellplugin/util/ItemNameFormatter.java b/src/main/java/com/yourname/sellplugin/util/ItemNameFormatter.java new file mode 100644 index 0000000..fd93919 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/util/ItemNameFormatter.java @@ -0,0 +1,31 @@ +package com.yourname.sellplugin.util; + +import java.util.Locale; + +public final class ItemNameFormatter { + + private ItemNameFormatter() { + } + + public static String formatKey(String key) { + if (key == null || key.isBlank()) return ""; + + String[] parts = key.split(":", 2); + String materialName = formatWords(parts[0]); + if (parts.length == 1) return materialName; + + return materialName + " (" + formatWords(parts[1]) + ")"; + } + + private static String formatWords(String raw) { + String[] words = raw.toLowerCase(Locale.ENGLISH).split("[_ ]+"); + StringBuilder builder = new StringBuilder(); + for (String word : words) { + if (word.isEmpty()) continue; + if (!builder.isEmpty()) builder.append(' '); + builder.append(Character.toUpperCase(word.charAt(0))); + builder.append(word.substring(1)); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/yourname/sellplugin/util/NumberFormatter.java b/src/main/java/com/yourname/sellplugin/util/NumberFormatter.java new file mode 100644 index 0000000..33cf100 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/util/NumberFormatter.java @@ -0,0 +1,47 @@ +package com.yourname.sellplugin.util; + +/** + * Formats large numbers into compact, human-readable strings. + * Examples: 1245 → "1.2k", 1_234_567 → "1.2m", 30.5 → "30.50", 36 → "36" + */ +public final class NumberFormatter { + + private NumberFormatter() {} + + private static final double THOUSAND = 1_000.0; + private static final double MILLION = 1_000_000.0; + private static final double BILLION = 1_000_000_000.0; + private static final double TRILLION = 1_000_000_000_000.0; + + /** + * Converts a double to a compact string: + *

    + *
  • ≥ 1t → e.g. "1.2t"
  • + *
  • ≥ 1b → e.g. "3.4b"
  • + *
  • ≥ 1m → e.g. "5.6m"
  • + *
  • ≥ 1k → e.g. "7.8k"
  • + *
  • Whole → e.g. "36"
  • + *
  • Other → e.g. "12.50"
  • + *
+ */ + public static String format(double value) { + if (value < 0) return "-" + format(-value); + + if (value >= TRILLION) return suffix(value / TRILLION, "t"); + if (value >= BILLION) return suffix(value / BILLION, "b"); + if (value >= MILLION) return suffix(value / MILLION, "m"); + if (value >= THOUSAND) return suffix(value / THOUSAND, "k"); + + // Small values + long asLong = (long) value; + if (value == asLong) return Long.toString(asLong); + return String.format("%.2f", value); + } + + private static String suffix(double divided, String unit) { + // 1 decimal place; strip trailing ".0" + String s = String.format("%.1f", divided); + if (s.endsWith(".0")) s = s.substring(0, s.length() - 2); + return s + unit; + } +} diff --git a/src/main/java/com/yourname/sellplugin/util/Scheduler.java b/src/main/java/com/yourname/sellplugin/util/Scheduler.java new file mode 100644 index 0000000..0e6aa43 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/util/Scheduler.java @@ -0,0 +1,64 @@ +package com.yourname.sellplugin.util; + +import com.yourname.sellplugin.SellPlugin; +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; + +/** + * Thin wrapper over the Paper/Folia scheduler APIs so the plugin behaves + * correctly on both regular Paper/Spigot and on Folia (multi-threaded regions). + * + *

On Folia there is no single "main thread": entities live in region threads + * that can move between CPUs, so {@code Bukkit.getScheduler()} throws. The + * region/entity/global/async schedulers used here are part of the Paper API and + * are implemented on regular Paper too, where they simply run on the main + * server thread — which lets the whole plugin share one code path. + * + *

    + *
  • Entity tasks run on the thread that currently owns that entity's + * region — the only safe place to touch a player, their inventory, etc.
  • + *
  • Global tasks run on the global region tick thread — for work not + * tied to any single entity/location.
  • + *
  • Async tasks run off any region thread — for I/O and other work + * that must never touch game state directly.
  • + *
+ */ +public final class Scheduler { + + private Scheduler() { + } + + /** + * Runs {@code task} on the region owning {@code entity}, {@code delayTicks} + * later (minimum 1 tick — Folia rejects a zero/negative delay). If the + * entity is removed before it fires, the task is silently dropped. + */ + public static void runEntityLater(SellPlugin plugin, Entity entity, Runnable task, long delayTicks) { + long delay = Math.max(1L, delayTicks); + entity.getScheduler().runDelayed(plugin, scheduled -> task.run(), null, delay); + } + + /** + * Runs {@code task} on the region owning {@code entity} as soon as possible. + * Dropped if the entity is removed first. + */ + public static void runEntity(SellPlugin plugin, Entity entity, Runnable task) { + entity.getScheduler().run(plugin, scheduled -> task.run(), null); + } + + /** Runs {@code task} on the global region, {@code delayTicks} later (min 1). */ + public static void runGlobalLater(SellPlugin plugin, Runnable task, long delayTicks) { + long delay = Math.max(1L, delayTicks); + Bukkit.getGlobalRegionScheduler().runDelayed(plugin, scheduled -> task.run(), delay); + } + + /** Runs {@code task} on the global region as soon as possible. */ + public static void runGlobal(SellPlugin plugin, Runnable task) { + Bukkit.getGlobalRegionScheduler().run(plugin, scheduled -> task.run()); + } + + /** Runs {@code task} off any region thread (for blocking I/O and the like). */ + public static void runAsync(SellPlugin plugin, Runnable task) { + Bukkit.getAsyncScheduler().runNow(plugin, scheduled -> task.run()); + } +} diff --git a/src/main/java/com/yourname/sellplugin/util/SmallCaps.java b/src/main/java/com/yourname/sellplugin/util/SmallCaps.java new file mode 100644 index 0000000..3a9b206 --- /dev/null +++ b/src/main/java/com/yourname/sellplugin/util/SmallCaps.java @@ -0,0 +1,37 @@ +package com.yourname.sellplugin.util; + +/** + * Converts regular lowercase text to Unicode small-capital letters. + * Uppercase letters are left as-is; digits, symbols, spaces, and + * colour codes are preserved. + */ +public final class SmallCaps { + + private SmallCaps() {} + + private static final String LOWER = + "abcdefghijklmnopqrstuvwxyz"; + private static final String SMALL = + "\u1d00\u0299\u1d04\u1d05\u1d07\ua730\u0262\u029c\u026a\u1d0a\u1d0b\u029f\u1d0d\u0274\u1d0f\u1d18\u01eb\u0280\ua731\u1d1b\u1d1c\u1d20\u1d21x\u028f\u1d22"; + + /** + * Convert every ASCII lowercase letter in {@code text} to its + * small-capital equivalent. Everything else (uppercase, digits, + * Minecraft colour codes like {@code §a}, spaces, symbols) is + * kept unchanged. + */ + public static String convert(String text) { + if (text == null) return null; + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + int idx = LOWER.indexOf(c); + if (idx >= 0) { + sb.append(SMALL.charAt(idx)); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 98300d5..a4c32b0 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,33 +1,389 @@ -# ========================================== # -# SELL PLUGIN CONFIG # -# ========================================== # +# ============================================================ # +# SELL PLUGIN CONFIG # +# ============================================================ # -# The amount the multiplier increases per 1 item sold in a category. -# Example: 0.001 means selling 1000 items gives a +1.0x bonus (Total 2.0x multiplier) -multiplier-step: 0.001 +# Internal schema version. DO NOT edit this by hand – the plugin uses it to +# auto-migrate old configs (adding any new options you're missing) and it makes +# a timestamped backup of your old file first. If this key is missing the plugin +# assumes an old config and migrates anyway. +config-version: 1 + +# ---- Feature toggles --------------------------------------- # +# Turn any major part of the plugin on or off. Everything defaults to on. +features: + # Category sell multipliers (earn more the more you sell in a category). + # When off, everything sells at a flat 1.0x. + multipliers: true + # Add extra value for enchantments (see the "enchantments" section below). + enchantment-pricing: true + # Allow selling the contents of shulker boxes. + shulker-selling: true + # The /sellmulti multiplier / progress-bar GUI. + progress-gui: true + # The /topsell leaderboard. + top-sell: true + # The "+$amount" action-bar pop-up shown after a sale. + action-bar: true + +# ---- Enchantment pricing ----------------------------------- # +# When enchantment-pricing is enabled, an item's price is calculated as: +# (base price + sum of each enchantment's value × its level) +# × (multiplier-per-enchantment ^ number of distinct enchantments) +# Example: a base $100 sword with Sharpness V and Unbreaking III, using the +# defaults below (value 50/level, factor 1.1): +# added = 50×5 + 50×3 = 400 → base+added = 500 +# factor = 1.1 ^ 2 distinct enchants = 1.21 +# price = 500 × 1.21 = $605 +enchantments: + # Value added per enchantment level for any enchantment not listed below. + default-value-per-level: 50.0 + # The price is multiplied by this for EACH distinct enchantment on the item. + multiplier-per-enchantment: 1.1 + # Per-enchantment overrides. Keys are the vanilla enchantment ids (lowercase). + # Anything not listed uses default-value-per-level above. + values: + sharpness: 60.0 + protection: 40.0 + mending: 300.0 + unbreaking: 30.0 + efficiency: 40.0 + fortune: 150.0 + silk_touch: 200.0 + +# Money required to unlock the first multiplier level (1.1x). +# Each subsequent level costs (previous cost × multiplier). +# e.g. start-multiplier: 1000, multiplier: 1.6 +# → 1.1x costs $1000, 1.2x costs $1600, 1.3x costs $2560 … +start-multiplier: 1000.0 +multiplier: 1.6 + +# Maximum multiplier cap (used for progress bar display) +max-multiplier: 3.0 + +# ---- Progress bar (snake path) --------------------------------------------- # +# The /sellmulti → category progress menu draws a "snake" of milestone nodes. +# Each node is a block that changes appearance based on its state. Any valid +# Material works here (glass panes, wool, concrete, ores, …) – customise freely. +# The text on every node is fully customisable under messages.category-progress. +progress-bar: + completed-color: "LIME_STAINED_GLASS_PANE" + inprogress-color: "YELLOW_STAINED_GLASS_PANE" + locked-color: "GRAY_STAINED_GLASS_PANE" + +# ---- Prefix / Notifications -------------------------------- # + +# Show a chat prefix message after selling? +# Set to false to disable chat messages. Action bar is always shown. +prefix-enabled: false + +# Play a sound when items are sold? +sounds-enabled: true + +# Sound to play on sell (Bukkit Sound enum name) +sound-type: "ENTITY_EXPERIENCE_ORB_PICKUP" + +# Show a large title pop-up (+$amount) in the centre of the screen on sell? +title-notification-enabled: false + +# ---- Economy ----------------------------------------------- # + +# VAULT or COINSENGINE +economy-mode: VAULT + +# CoinsEngine currency ID (only used if economy-mode: COINSENGINE) +coinsengine-currency-id: coins + +# ---- GUI --------------------------------------------------- # + +# Filler block used as background in GUIs (must be a valid Material name) +filler-block: "BLACK_STAINED_GLASS_PANE" + +# ---- Sell worth tooltip -------------------------------------- # +# Adds a client-side worth line to sellable items using packets. +# Supports the {worth} placeholder. +worth: + enabled: true + format: "&7Worth &a&l${worth}" + # Show the worth line to players in CREATIVE mode? + # Leave this false. Creative clients echo item lore back to the server, which + # bakes the worth line into the real item and causes duplicate lines. Only + # enable if you fully understand that trade-off. + show-in-creative: false + +# ---- Main Shop GUI (/sell) ---------------------------------- # +# This opens the 9x6 sell GUI where players place items to sell. +# The bottom-right corner has a "Sell" button (lime glass pane). +# Closing the GUI also sells all items inside. -# GUI Settings gui: - title: "&8&lSell Menu" + title: "&8&lShop" + size: 54 + +# ---- Sell All GUI (/sellall) -------------------------------- # +# Simple one-button GUI to sell everything in your inventory. + +sell-all-gui: + title: "&8&lSell All Items" size: 27 - sell-all-slot: 13 - sell-all-item: "EMERALD_BLOCK" - sell-all-name: "&a&lSell All Items" - sell-all-lore: + slot: 13 + # Material of the sell-all button (change to any Minecraft block/item name) + item: "EMERALD_BLOCK" + name: "&a&lSell All Items" + lore: - "&7Click to sell all sellable items" - "&7in your inventory." - "" - "&eYour current Multipliers:" - "{multipliers}" +# ---- Sell Multi GUI (/sellmulti) ----------------------------- # +# 1x9 GUI showing all category multipliers at a glance. +# Clicking a category opens its progress path. -economy-mode: VAULT -coinsengine-currency-id: coins +sell-multi-gui: + title: "&8&lMultipliers" + +# ---- Worth GUI (/sellworth, /worth) -------------------------- # +# Paginated item prices browser with category filter. + +worth-gui: + title: "&8&lItem Prices" + filter-all: "&e&lFILTER: &fAll" + filter-category: "&e&lFILTER: &f" + +# ---- Category order (shown in /sellmulti and /sellworth) ---- # +# These must match the top-level keys in price.yml. +# Up to 9 entries shown in the multiplier GUI. +category-order: + - armortools + - blocks + - crops + - enchantedbooks + - fish + - mobdrops + - naturalitems + - ores + - potions -# Messages +# ---- Category display settings ------------------------------ # +# Customise the icon and name shown for each category button. +# 'material' must be a valid Minecraft item name. + +categories: + armortools: + display-name: "&6Armor & Tools" + material: "DIAMOND_SWORD" + lore: + - "&7Sell your armor, weapons," + - "&7and tools." + blocks: + display-name: "&7Blocks" + material: "STONE" + lore: + - "&7Sell building blocks." + crops: + display-name: "&aFarming" + material: "WHEAT" + lore: + - "&7Sell crops and farm produce." + enchantedbooks: + display-name: "&5Enchanted Books" + material: "ENCHANTED_BOOK" + lore: + - "&7Sell enchanted books." + fish: + display-name: "&bFishing" + material: "COD" + lore: + - "&7Sell fish and fishing loot." + mobdrops: + display-name: "&cMob Drops" + material: "BONE" + lore: + - "&7Sell mob loot and drops." + naturalitems: + display-name: "&2Nature" + material: "OAK_LOG" + lore: + - "&7Sell logs, leaves, and" + - "&7other natural items." + ores: + display-name: "&eOres & Gems" + material: "IRON_ORE" + lore: + - "&7Sell ores and precious gems." + potions: + display-name: "&dPotions" + material: "POTION" + lore: + - "&7Sell potions and brewed items." + +# ---- GUI Icons --------------------------------------------- # +# Customise every navigation / action icon used across all GUIs. +# 'material' must be a valid Minecraft item name. +# 'name' and each 'lore' line support & colour codes. + +icons: + # ── Shared navigation ────────────────────────────────────────── + back: + material: "ARROW" + name: "&c&lBack" + lore: + - "&7Return to the previous menu." + + prev-page: + material: "ARROW" + name: "&e← Previous" + lore: + - "&7Go to the previous page." + + next-page: + material: "ARROW" + name: "&eNext →" + lore: + - "&7Go to the next page." + + page-indicator: + material: "PAPER" + # Name is generated dynamically as "Page X / Y". + # Only material is used from this entry. + + # ── Confirm / Cancel ─────────────────────────────────────────── + confirm: + material: "LIME_STAINED_GLASS_PANE" + name: "&a&lConfirm" + + cancel: + material: "RED_STAINED_GLASS_PANE" + name: "&c&lCancel" + lore: + - "&7Go back without selling." + + # ── Sell button (in /sell GUI, bottom-right) ────────────────── + sell-button: + material: "LIME_STAINED_GLASS_PANE" + name: "&a&lSell" + + # ── Category Items GUI ───────────────────────────────────────── + sell-category: + material: "GOLD_INGOT" + name: "&a&lSell Category" + + # ── Confirm Sell-All info icon ───────────────────────────────── + sell-all-info: + material: "CHEST" + name: "&f&lSell All" + + # ── TopSell leaderboard ───────────────────────────────────────── + topsell-close: + material: "BARRIER" + name: "&c&lClose" + lore: + - "&7Close the leaderboard." + +# ---- Messages ---------------------------------------------- # +# Every piece of text shown to players can be customised here. +# & colour codes are supported. Placeholders are listed per entry. messages: prefix: "&8[&aSellPlugin&8] " no-permission: "&cYou do not have permission for this." sold-items: "&aYou sold &e{amount} &aitems for &e${price}&a!" nothing-to-sell: "&cYou have no sellable items in your inventory." economy-error: "&cAn economy error occurred. Please contact an admin." + # Shown when a player uses a command for a feature that's toggled off. + feature-disabled: "&cThis feature is currently disabled." + + # ── Commands / console messages ─────────────────────────────── + player-only-command: "&cOnly players can use this command." + reload-no-permission: "&cYou do not have permission to reload the config." + reload-success: "&aSellPlugin configuration reloaded." + + # ── /showworth (per-player worth tooltip toggle) ────────────── + showworth-usage: "&cUsage: /showworth [true|false]" + showworth-enabled: "&aItem worth is now &lshown&r&a in your inventory." + showworth-disabled: "&eItem worth is now &lhidden&r&e in your inventory." + + # ── Shared decorative separator used in lots of lore lists ──── + lore-separator: "&8━━━━━━━━━━━━━━━━━━━" + + # ── Sell notification (title pop-up, {count} = items sold) ──── + action-bar: "&a+${amount}" + sell-title: "&a+${amount}" + sell-subtitle: "&7You sold {count} item(s)" + + # ── Main Shop GUI (/sell) ────────────────────────────────────── + shop: + # Inventory title. Rendered in small caps automatically. + title: "put items here to sell" + sell-button-name: "&a&lSell" + sell-value-label: "value: " + sell-empty: "no sellable items" + sell-click: "click to sell all items!" + + # ── Sell Multi GUI (/sellmulti) ──────────────────────────────── + sellmulti: + title: "&8&lMultipliers" + earned-label: "earned: " + effective-label: "effective: " + click-to-view: "click to view progress" + + # ── Category Items GUI (list of items in a category) ────────── + category-items: + title-suffix: "&8 – Items" + total-items: "&7Total items: {count}" + page-indicator: "&fPage {page} / {total}" + category-label: "category: " + items-label: "items: " + earn-label: "earn: " + no-items-to-sell: "no items to sell." + + # ── Category Progress GUI (multiplier snake path) ───────────── + category-progress: + back-lore: "return to the main menu." + node-multiplier-suffix: "multiplier" + node-status-label: "status: " + node-status-completed: "completed" + node-status-in-progress: "in progress" + node-status-locked: "locked" + node-click-to-view: "click to view items & prices" + node-earned-label: "earned: " + node-required-label: "required: " + node-progress-label: "progress: " + node-need-label: "need: " + node-need-suffix: "more to unlock" + + # ── Confirm Sell (category) GUI ──────────────────────────────── + confirm-sell: + title-prefix: "sell your " + items-label: "items: " + value-label: "value: " + confirm-lore-line1: "sell all " + confirm-lore-line2: " items" + confirm-lore-line3: "from your inventory." + confirm-earn: "you will earn: $" + cancel-lore: "go back without selling." + + # ── Confirm Sell-All GUI ─────────────────────────────────────── + confirm-sell-all: + title: "confirm sell all" + items-label: "items: " + value-label: "value: " + confirm-lore: "sell all items from your inventory." + confirm-earn: "you will earn: $" + cancel-lore: "go back without selling." + + # ── Top Sellers Leaderboard GUI ──────────────────────────────── + top-sell: + title: "top sellers" + close-lore: "close the leaderboard." + prev-page-lore: "previous page." + next-page-lore: "next page." + total-players: "total players: " + page-indicator: "page {page} / {total}" + total-earned-label: "total earned: " + + # ── Worth GUI (/sellworth, /worth) ───────────────────────────── + worth-gui: + title: "&8&lItem Prices" + filter-all: "&e&lFILTER: &fAll" + filter-category: "&e&lFILTER: &f" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index fee2c13..7cdae6e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,9 +1,53 @@ name: SellPlugin -version: 1.0 +version: 2.3.0 main: com.yourname.sellplugin.SellPlugin api-version: 1.13 -softdepend: [Vault, CoinsEngine] +folia-supported: true +softdepend: [Vault, CoinsEngine, ProtocolLib] + commands: sell: - description: Opens the sell menu. - aliases: [sellmenu, sellgui] + description: Opens the sell GUI where you can place items to sell. + usage: /sell [reload] + aliases: [sellmenu, sellgui, shop] + permission: sellplugin.use + sellmulti: + description: Opens the multiplier overview GUI. + usage: /sellmulti + aliases: [multipliers, sellmultiplier] + permission: sellplugin.use + sellworth: + description: Opens the item prices GUI to browse item values. + usage: /sellworth + aliases: [worth, prices, itemprices] + permission: sellplugin.use + showworth: + description: Show or hide the item worth tooltip for yourself. + usage: /showworth [true|false] + aliases: [toggleworth, worthdisplay] + permission: sellplugin.use + sellall: + description: Opens the quick sell-all GUI. + usage: /sellall + permission: sellplugin.use + fastsellall: + description: Instantly sells all sellable items without opening a GUI. + usage: /fastsellall + permission: sellplugin.use + + topsell: + description: Opens the top sellers leaderboard. + usage: /topsell + aliases: [sellertop, leaderboard] + permission: sellplugin.topsell + +permissions: + sellplugin.use: + description: Allows a player to use the sell commands. + default: true + sellplugin.topsell: + description: Allows a player to view the top sellers leaderboard. + default: true + sellplugin.reload: + description: Allows a player to reload the plugin configuration. + default: op