From b45f5aecfe93f858eb7eb6d9a127ccc422551d12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 05:24:48 +0000 Subject: [PATCH 1/2] Match worth marker with contains() to stop creative re-baking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creative clients echo lore back via SET_CREATIVE_SLOT, and Minecraft can prepend a formatting code (e.g. §f) to each lore line on serialization. That broke the startsWith() marker check, so the injected worth line was neither stripped on the inbound packet nor de-duplicated on redraw, causing it to bake into the real item and show duplicates on close/reopen. Detect the marker anywhere in the line (contains) for both stripping and de-dup, and treat formatting-only lines as blank separators. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016mt4AzbsKNLtCpekgmSGGU --- .../listener/WorthPacketListener.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java index 8b45b8d..9552106 100644 --- a/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java +++ b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java @@ -143,7 +143,7 @@ private ItemStack addWorthLore(Player player, ItemStack original) { removeWorthLines(lore); if (worth > 0) { - if (!lore.isEmpty()) { + if (!lore.isEmpty() && !isBlank(lore.get(lore.size() - 1))) { lore.add(""); } lore.add(WORTH_MARKER + plugin.getConfigManager().getWorthFormat() @@ -177,7 +177,7 @@ private ItemStack stripWorthLore(ItemStack item) { private boolean loreHasWorthLine(List lore) { for (String line : lore) { - if (line != null && line.startsWith(WORTH_MARKER)) return true; + if (line != null && line.contains(WORTH_MARKER)) return true; } return false; } @@ -190,17 +190,38 @@ private boolean removeWorthLines(List lore) { boolean changed = false; for (int i = lore.size() - 1; i >= 0; i--) { String line = lore.get(i); - if (line == null || !line.startsWith(WORTH_MARKER)) continue; + // 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 && lore.get(i - 1).isEmpty()) { + 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; + } + private boolean shouldDecorate(Player player) { Inventory topInventory = player.getOpenInventory().getTopInventory(); InventoryHolder holder = topInventory.getHolder(); From cab8a9c56a0bb1fafe0fd1973361b5db79314fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 16:06:41 +0000 Subject: [PATCH 2/2] Add /showworth per-player toggle and full Folia support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /showworth [true|false] lets each player show or hide the worth tooltip for themselves (no arg toggles). The preference persists in worth-visibility.yml and the packet decorator now gates on it per-viewer; the view refreshes immediately on change. Folia: declare folia-supported and route every scheduled task through a new Scheduler helper that uses the region/entity/global/async schedulers (part of the Paper API, so the same code runs on Paper too). The three global-scheduler calls that would throw on Folia — inventory-refresh nudge, sell-button refresh, and staggered leaderboard heads — now run on the owning player's region thread; visibility saves run on the async scheduler. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016mt4AzbsKNLtCpekgmSGGU --- .../com/yourname/sellplugin/SellPlugin.java | 11 ++ .../sellplugin/command/ShowWorthCommand.java | 88 ++++++++++++++ .../yourname/sellplugin/gui/GUIListener.java | 6 +- .../yourname/sellplugin/gui/TopSellGUI.java | 4 +- .../listener/WorthPacketListener.java | 7 +- .../listener/WorthRefreshListener.java | 6 +- .../manager/WorthVisibilityManager.java | 107 ++++++++++++++++++ .../yourname/sellplugin/util/Scheduler.java | 64 +++++++++++ src/main/resources/config.yml | 5 + src/main/resources/plugin.yml | 6 + 10 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/yourname/sellplugin/command/ShowWorthCommand.java create mode 100644 src/main/java/com/yourname/sellplugin/manager/WorthVisibilityManager.java create mode 100644 src/main/java/com/yourname/sellplugin/util/Scheduler.java diff --git a/src/main/java/com/yourname/sellplugin/SellPlugin.java b/src/main/java/com/yourname/sellplugin/SellPlugin.java index beb29a7..c791f3b 100644 --- a/src/main/java/com/yourname/sellplugin/SellPlugin.java +++ b/src/main/java/com/yourname/sellplugin/SellPlugin.java @@ -4,6 +4,7 @@ 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; @@ -15,6 +16,7 @@ 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 { @@ -25,6 +27,7 @@ public class SellPlugin extends JavaPlugin { private MultiplierManager multiplierManager; private DailyBonusManager dailyBonusManager; private SellManager sellManager; + private WorthVisibilityManager worthVisibilityManager; private WorthPacketListener worthPacketListener; @Override @@ -38,6 +41,7 @@ public void onEnable() { multiplierManager = new MultiplierManager(this); dailyBonusManager = new DailyBonusManager(this); sellManager = new SellManager(this); + worthVisibilityManager = new WorthVisibilityManager(this); economyManager = new EconomyManager(this); if (!economyManager.setupEconomy()) { @@ -52,6 +56,9 @@ public void onEnable() { 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); @@ -66,6 +73,9 @@ public void onDisable() { if (multiplierManager != null) { multiplierManager.saveAll(); } + if (worthVisibilityManager != null) { + worthVisibilityManager.saveNow(); + } if (worthPacketListener != null) { worthPacketListener.unregister(); } @@ -78,4 +88,5 @@ public void onDisable() { public MultiplierManager getMultiplierManager() { return multiplierManager; } public DailyBonusManager getDailyBonusManager() { return dailyBonusManager; } public SellManager getSellManager() { return sellManager; } + public WorthVisibilityManager getWorthVisibilityManager() { return worthVisibilityManager; } } 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/gui/GUIListener.java b/src/main/java/com/yourname/sellplugin/gui/GUIListener.java index c1af6ec..a9a1af7 100644 --- a/src/main/java/com/yourname/sellplugin/gui/GUIListener.java +++ b/src/main/java/com/yourname/sellplugin/gui/GUIListener.java @@ -2,6 +2,7 @@ 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; @@ -89,8 +90,9 @@ public void onClick(InventoryClickEvent e) { } // Slots 0-44: allow item placement / removal - // After any click, schedule a sell button refresh - plugin.getServer().getScheduler().runTaskLater(plugin, shopGUI::refreshSellButton, 1L); + // 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; } diff --git a/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java b/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java index 80b89d1..3734f6a 100644 --- a/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java +++ b/src/main/java/com/yourname/sellplugin/gui/TopSellGUI.java @@ -4,6 +4,7 @@ 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; @@ -89,7 +90,8 @@ private void populate() { 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). - Bukkit.getScheduler().runTaskLater(plugin, () -> { + // 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)); } diff --git a/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java index 9552106..584c4af 100644 --- a/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java +++ b/src/main/java/com/yourname/sellplugin/listener/WorthPacketListener.java @@ -59,8 +59,11 @@ public void register() { @Override public void onPacketSending(PacketEvent event) { - if (!WorthPacketListener.this.plugin.getConfigManager().isWorthEnabled()) return; - if (!shouldDecorate(event.getPlayer())) return; + Player viewer = event.getPlayer(); + if (viewer == null) return; + if (!WorthPacketListener.this.plugin.getWorthVisibilityManager() + .isVisible(viewer.getUniqueId())) return; + if (!shouldDecorate(viewer)) return; if (event.getPacketType() == PacketType.Play.Server.SET_SLOT) { if (event.getPacket().getItemModifier().size() <= 0) return; diff --git a/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java b/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java index f2dbbfb..6f00e47 100644 --- a/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java +++ b/src/main/java/com/yourname/sellplugin/listener/WorthRefreshListener.java @@ -1,6 +1,7 @@ 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; @@ -47,8 +48,9 @@ public void onJoin(PlayerJoinEvent event) { private void refresh(Player player) { if (!plugin.getConfigManager().isWorthEnabled()) return; - // Run next tick so the inventory reflects the change that triggered us. - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + // 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(); } 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/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/resources/config.yml b/src/main/resources/config.yml index 27db1a3..6beef97 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -250,6 +250,11 @@ messages: 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━━━━━━━━━━━━━━━━━━━" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 14835d3..fee4b92 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,6 +2,7 @@ name: SellPlugin version: 2.2.0 main: com.yourname.sellplugin.SellPlugin api-version: 1.13 +folia-supported: true softdepend: [Vault, CoinsEngine, ProtocolLib] commands: @@ -20,6 +21,11 @@ commands: 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