From b353836787f01be65894be1cb208ff224a2be293 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:53:07 +0200 Subject: [PATCH 1/2] Move the scrap recovery lore to messages.yml with colours The alloy scrap preview line was hardcoded as uncoloured "Recovery chance: 50.0% per material", which the client showed purple and italic. It is now preview.scrap_chance in messages.yml: a list of lines with & and hex colours, and %item% (the material's own coloured name), %chance% and %amount% (the most that can come back, which the 64-capped stack hides). messages.yml keys missing from a server's copy now fall back to the bundled file, so existing servers get the new lore without editing theirs. All messages accept &#RRGGBB / #RRGGBB. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/recycler/Messages.java | 68 ++++++++++++++++--- .../recycler/manager/InventoryManager.java | 62 +++++++++++++++-- src/main/resources/messages.yml | 32 +++++++-- .../recycler/InventoryManagerTest.java | 56 ++++++++++++++- .../net/tfminecraft/recycler/LoadersTest.java | 19 ++++++ 5 files changed, 215 insertions(+), 22 deletions(-) diff --git a/src/main/java/net/tfminecraft/recycler/Messages.java b/src/main/java/net/tfminecraft/recycler/Messages.java index 2ae4b21..89b214e 100644 --- a/src/main/java/net/tfminecraft/recycler/Messages.java +++ b/src/main/java/net/tfminecraft/recycler/Messages.java @@ -3,17 +3,23 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; /** - * Player-facing strings from messages.yml. + * Player-facing strings from messages.yml. Keys missing from the server's copy fall back to the bundled file. */ public final class Messages { + private static final Pattern HEX = Pattern.compile("&?#([0-9a-fA-F]{6})"); + private static FileConfiguration config; private Messages() {} @@ -22,33 +28,79 @@ public static void load(File file) { FileConfiguration loaded = new YamlConfiguration(); try { loaded.load(file); - config = loaded; } catch (IOException | InvalidConfigurationException ex) { Recycler.plugin.getLogger().severe("[Recycler] Failed to load messages.yml: " + ex.getMessage()); - config = new YamlConfiguration(); + loaded = new YamlConfiguration(); + } + FileConfiguration bundled = bundled(); + if (bundled != null) { + loaded.setDefaults(bundled); } + config = loaded; } public static void loadFromResources() { + FileConfiguration bundled = bundled(); + if (bundled != null) { + config = bundled; + } + } + + /** The jar's messages.yml, or null. A stream that fails only to close still counts as read. */ + private static FileConfiguration bundled() { + FileConfiguration bundled = null; try { InputStream resource = Recycler.plugin.getResource("messages.yml"); if (resource == null) { - return; + return null; } try (InputStream in = resource) { FileConfiguration loaded = new YamlConfiguration(); - loaded.loadFromString(new String(in.readAllBytes())); - config = loaded; + loaded.loadFromString(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + bundled = loaded; } } catch (Exception ex) { Recycler.plugin.getLogger().warning("[Recycler] Failed to load bundled messages.yml: " + ex.getMessage()); } + return bundled; } public static String get(String path) { if (config == null) { return path; } - return config.getString(path, path); + // getString(path, def) would skip the bundled defaults. + String message = config.getString(path); + return colorize(message != null ? message : path); + } + + /** A message written as a list of lines, or as one line. Missing or [] gives no lines. */ + public static List getList(String path) { + List lines = new ArrayList<>(); + if (config == null) { + return lines; + } + if (config.isList(path)) { + for (String line : config.getStringList(path)) { + lines.add(colorize(line)); + } + } else if (config.isString(path)) { + lines.add(colorize(config.getString(path))); + } + return lines; + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + // Hex colours may be written #RRGGBB or &#RRGGBB; the & goes with the colour. + @SuppressWarnings("deprecation") + public static String colorize(String text) { + Matcher match = HEX.matcher(text); + StringBuilder colored = new StringBuilder(); + while (match.find()) { + match.appendReplacement(colored, + Matcher.quoteReplacement(net.md_5.bungee.api.ChatColor.of("#" + match.group(1)).toString())); + } + match.appendTail(colored); + return org.bukkit.ChatColor.translateAlternateColorCodes('&', colored.toString()); } } diff --git a/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java b/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java index 0bff89d..e861179 100644 --- a/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java +++ b/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java @@ -1,12 +1,22 @@ package net.tfminecraft.recycler.manager; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import net.tfminecraft.recycler.GuiCache; +import net.tfminecraft.recycler.Messages; import net.tfminecraft.recycler.Recycler; import net.tfminecraft.recycler.gui.RecyclerGuiHolder; +import net.tfminecraft.recycler.model.RecycleOutput; import net.tfminecraft.recycler.model.RecycleSession; import net.tfminecraft.recycler.provider.RecycleProviderChain; import net.tfminecraft.recycler.provider.RecycleResult; @@ -102,15 +112,57 @@ private void paintPreview(org.bukkit.inventory.Inventory inv, RecycleResult resu } stack.setAmount(Math.min(64, output.baseAmount())); if (output.returnChance() >= 0) { - var meta = stack.getItemMeta(); - var lore = meta.hasLore() ? new java.util.ArrayList<>(meta.getLore()) : new java.util.ArrayList(); - lore.add(String.format(java.util.Locale.ROOT, "Recovery chance: %.1f%% per material", output.returnChance() * 100)); - meta.setLore(lore); - stack.setItemMeta(meta); + addChanceLore(stack, output); } inv.setItem(GridLayout.previewSlots().get(index), stack); index++; } } + /** Appends preview.scrap_chance from messages.yml under the item's own lore. */ + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + private void addChanceLore(ItemStack stack, RecycleOutput output) { + List template = Messages.getList("preview.scrap_chance"); + if (template.isEmpty()) { + return; + } + String name = itemName(stack); + String chance = formatChance(output.returnChance()); + String amount = Integer.toString(output.baseAmount()); + var meta = stack.getItemMeta(); + List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); + for (String line : template) { + lore.add(line.replace("%item%", name).replace("%chance%", chance).replace("%amount%", amount)); + } + meta.setLore(lore); + stack.setItemMeta(meta); + } + + /** The item's own name with its colour; plain vanilla items get their material name in white. */ + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + private static String itemName(ItemStack stack) { + ItemMeta meta = stack.getItemMeta(); + if (meta.hasDisplayName()) { + return meta.getDisplayName(); + } + if (meta.hasItemName()) { + return meta.getItemName(); + } + StringBuilder name = new StringBuilder(); + for (String word : stack.getType().name().toLowerCase(Locale.ROOT).split("_")) { + if (!name.isEmpty()) { + name.append(' '); + } + name.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return ChatColor.WHITE + name.toString(); + } + + /** 0.5 -> "50%", 0.255 -> "25.5%". */ + private static String formatChance(double chance) { + return new DecimalFormat("0.#", DecimalFormatSymbols.getInstance(Locale.ROOT)).format(chance * 100) + "%"; + } + } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index f628594..828eaaa 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -1,12 +1,30 @@ +# All text the plugin shows players. +# Supports & color codes and hex colors (&#RRGGBB or #RRGGBB). +# Keys missing from this file use the plugin's built-in text. + reload: - success: "§a[Recycler] Reloaded configs." - failed: "§c[Recycler] Reload failed - check console." + success: "&a[Recycler] Reloaded configs." + failed: "&c[Recycler] Reload failed - check console." station: - not_recyclable: "§cThat item cannot be recycled here." - blocked: "§cThat item cannot be deposited here." - zero_yield: "§cNothing would be returned from recycling this item." - recycled: "§aRecycling complete." + not_recyclable: "&cThat item cannot be recycled here." + blocked: "&cThat item cannot be deposited here." + zero_yield: "&cNothing would be returned from recycling this item." + recycled: "&aRecycling complete." escrow: - returned: "§7Your item was returned from the recycling station." + returned: "&7Your item was returned from the recycling station." + +preview: + # Lore added under the item's own lore on each possible alloy scrap return. + # The stack shown is the most that can come back; on confirm each unit rolls + # on its own, and a failed roll still uses up the scrap. + # %item% = the material's own name, in its own color + # (put a color after it, or its color runs on) + # %chance% = chance for each unit to come back, e.g. 50% + # %amount% = most units that can come back (the stack shown stops at 64) + # Use [] to add no lore. + scrap_chance: + - "" + - "&7Each %item% &7has a &#f0c75e%chance% &7chance" + - "&7to be recovered &8(up to %amount%)" diff --git a/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java b/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java index 4ca6578..af010e2 100644 --- a/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java +++ b/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java @@ -25,6 +25,9 @@ void chancePreviewShowsPossibleAmountAndPreservesExistingLore() throws Exception when(chain.resolve(input)).thenReturn(RecycleResult.of("scrap", List.of(new RecycleOutput("gem", 1, .01)), new RecycleContext(.5, 1, 1))); manager.openMain(player, session); + when(plugin.getResource("messages.yml")) + .thenAnswer(x -> getClass().getResourceAsStream("/messages.yml")); + Messages.load(dir.resolve("absent.yml").toFile()); for (boolean hasLore : List.of(false, true)) { when(api.getCreator().getItemFromPath("gem")).thenAnswer(x -> { var gem = item(Material.DIAMOND); @@ -36,10 +39,59 @@ void chancePreviewShowsPossibleAmountAndPreservesExistingLore() throws Exception manager.refreshPreview(player, session, escrow); var shown = player.getOpenInventory().getTopInventory().getItem(GridLayout.previewSlots().getFirst()); assertEquals(1, shown.getAmount()); - assertEquals("Recovery chance: 1.0% per material", shown.getItemMeta().getLore().getLast()); - assertEquals(hasLore ? 2 : 1, shown.getItemMeta().getLore().size()); + var lore = shown.getItemMeta().getLore(); + assertEquals(hasLore ? 4 : 3, lore.size()); + assertEquals("", lore.get(lore.size() - 3)); + assertEquals("§7Each §fDiamond §7has a §x§f§0§c§7§5§e1% §7chance", lore.get(lore.size() - 2)); + assertEquals("§7to be recovered §8(up to 1)", lore.getLast()); } } + + @Test + void chanceLoreUsesTheConfiguredLinesAndTheItemsOwnName() throws Exception { + var chain = mock(RecycleProviderChain.class); + var manager = new InventoryManager(chain); + var player = server.addPlayer(); + var session = new RecycleSession(player.getUniqueId(), token(player)); + var escrow = mock(EscrowManager.class); + var input = item(Material.IRON_NUGGET); + when(escrow.getEscrow(session.getToken().owner())).thenReturn(input); + when(chain.resolve(input)).thenReturn(RecycleResult.of("scrap", + List.of(new RecycleOutput("steel", 70, .255)), new RecycleContext(.5, 1, 1))); + when(api.getCreator().getItemFromPath("steel")).thenAnswer(x -> { + var steel = item(Material.IRON_INGOT); + var meta = steel.getItemMeta(); + meta.setDisplayName("§x§7§f§7§d§8§0Steel Ingot"); + steel.setItemMeta(meta); + return steel; + }); + manager.openMain(player, session); + var slot = GridLayout.previewSlots().getFirst(); + + Messages.load(yaml("one-line.yml", "preview:\n scrap_chance: \"%item% �FF00%chance% %amount%\"").toFile()); + manager.refreshPreview(player, session, escrow); + var shown = player.getOpenInventory().getTopInventory().getItem(slot); + assertEquals(64, shown.getAmount()); + assertEquals(List.of("§x§7§f§7§d§8§0Steel Ingot §x§0§0§f§f§0§025.5% 70"), shown.getItemMeta().getLore()); + + Messages.load(yaml("none.yml", "preview:\n scrap_chance: []").toFile()); + manager.refreshPreview(player, session, escrow); + assertFalse(player.getOpenInventory().getTopInventory().getItem(slot).getItemMeta().hasLore()); + } + + @Test + void itemNamesAndChanceFormatting() throws Exception { + var named = item(Material.DIAMOND); + var meta = named.getItemMeta(); + meta.setItemName("§bRuby"); + named.setItemMeta(meta); + assertEquals("§bRuby", invoke(InventoryManager.class, "itemName", new Class[] {org.bukkit.inventory.ItemStack.class}, named)); + assertEquals("§fIron Ingot", invoke(InventoryManager.class, "itemName", new Class[] {org.bukkit.inventory.ItemStack.class}, item(Material.IRON_INGOT))); + assertEquals("50%", invoke(InventoryManager.class, "formatChance", new Class[] {double.class}, .5)); + assertEquals("1%", invoke(InventoryManager.class, "formatChance", new Class[] {double.class}, .01)); + assertEquals("7%", invoke(InventoryManager.class, "formatChance", new Class[] {double.class}, .07)); + assertEquals("25.5%", invoke(InventoryManager.class, "formatChance", new Class[] {double.class}, .255)); + } @Test void shellPreviewReplacementAndCapacityLimit() throws Exception { var chain = mock(RecycleProviderChain.class); diff --git a/src/test/java/net/tfminecraft/recycler/LoadersTest.java b/src/test/java/net/tfminecraft/recycler/LoadersTest.java index 4ad39d4..cb58837 100644 --- a/src/test/java/net/tfminecraft/recycler/LoadersTest.java +++ b/src/test/java/net/tfminecraft/recycler/LoadersTest.java @@ -146,6 +146,25 @@ void messagesFallbackResourcesAndMalformedData() throws Exception { assertEquals("bundled", Messages.get("greet")); } + @Test + void messagesColorsListsAndBundledDefaults() throws Exception { + var field = Messages.class.getDeclaredField("config"); + field.setAccessible(true); + field.set(null, null); + assertEquals(List.of(), Messages.getList("lines")); + when(plugin.getResource("messages.yml")) + .thenAnswer(x -> new ByteArrayInputStream("greet: '&aBundled'\nlines: [a, b]\n".getBytes())); + Messages.load(yaml("server.yml", "own: '&#FF0000Red #00ff00green &lbold'\nlines: '&7one'\n").toFile()); + assertEquals("§x§F§F§0§0§0§0Red §x§0§0§f§f§0§0green §lbold", Messages.get("own")); + assertEquals("§aBundled", Messages.get("greet")); + assertEquals(List.of("§7one"), Messages.getList("lines")); + assertEquals(List.of(), Messages.getList("missing")); + Messages.load(yaml("empty.yml", "{}").toFile()); + assertEquals(List.of("a", "b"), Messages.getList("lines")); + Messages.load(dir.resolve("absent.yml").toFile()); + assertEquals("§aBundled", Messages.get("greet")); + } + @Test void recipesParseCurrentLegacyAliasesAndRejectMalformedEntries() throws Exception { var loader = new RecipeLoader(); From d1ea427c86a533cac985d6c79ec60cc9e18aaa2b Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:40 +0200 Subject: [PATCH 2/2] Fill lore placeholders in one pass over the template A material name containing %amount% or %chance% was rewritten by the later replace calls. Placeholders are now matched only in the template. Co-Authored-By: Claude Opus 5.5 --- .../recycler/manager/InventoryManager.java | 21 +++++++++++++++---- .../recycler/InventoryManagerTest.java | 5 +++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java b/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java index e861179..da9e184 100644 --- a/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java +++ b/src/main/java/net/tfminecraft/recycler/manager/InventoryManager.java @@ -5,6 +5,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.bukkit.ChatColor; import org.bukkit.Material; @@ -29,6 +32,8 @@ */ public final class InventoryManager { + private static final Pattern PLACEHOLDER = Pattern.compile("%(item|chance|amount)%"); + private final RecycleProviderChain providerChain; public InventoryManager(RecycleProviderChain providerChain) { @@ -127,13 +132,21 @@ private void addChanceLore(ItemStack stack, RecycleOutput output) { if (template.isEmpty()) { return; } - String name = itemName(stack); - String chance = formatChance(output.returnChance()); - String amount = Integer.toString(output.baseAmount()); + Map values = Map.of( + "item", itemName(stack), + "chance", formatChance(output.returnChance()), + "amount", Integer.toString(output.baseAmount())); var meta = stack.getItemMeta(); List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); for (String line : template) { - lore.add(line.replace("%item%", name).replace("%chance%", chance).replace("%amount%", amount)); + // One pass over the template, so a name containing %amount% is left as written. + Matcher match = PLACEHOLDER.matcher(line); + StringBuilder filled = new StringBuilder(); + while (match.find()) { + match.appendReplacement(filled, Matcher.quoteReplacement(values.get(match.group(1)))); + } + match.appendTail(filled); + lore.add(filled.toString()); } meta.setLore(lore); stack.setItemMeta(meta); diff --git a/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java b/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java index af010e2..bc708c6 100644 --- a/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java +++ b/src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java @@ -61,7 +61,7 @@ void chanceLoreUsesTheConfiguredLinesAndTheItemsOwnName() throws Exception { when(api.getCreator().getItemFromPath("steel")).thenAnswer(x -> { var steel = item(Material.IRON_INGOT); var meta = steel.getItemMeta(); - meta.setDisplayName("§x§7§f§7§d§8§0Steel Ingot"); + meta.setDisplayName("§x§7§f§7§d§8§0Steel %amount% $1 Ingot"); steel.setItemMeta(meta); return steel; }); @@ -72,7 +72,8 @@ void chanceLoreUsesTheConfiguredLinesAndTheItemsOwnName() throws Exception { manager.refreshPreview(player, session, escrow); var shown = player.getOpenInventory().getTopInventory().getItem(slot); assertEquals(64, shown.getAmount()); - assertEquals(List.of("§x§7§f§7§d§8§0Steel Ingot §x§0§0§f§f§0§025.5% 70"), shown.getItemMeta().getLore()); + // The name's own %amount% and $1 stay literal; only the template's placeholders are filled. + assertEquals(List.of("§x§7§f§7§d§8§0Steel %amount% $1 Ingot §x§0§0§f§f§0§025.5% 70"), shown.getItemMeta().getLore()); Messages.load(yaml("none.yml", "preview:\n scrap_chance: []").toFile()); manager.refreshPreview(player, session, escrow);