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