Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions src/main/java/net/tfminecraft/recycler/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand All @@ -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<String> getList(String path) {
List<String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
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 java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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;
Expand All @@ -19,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) {
Expand Down Expand Up @@ -102,15 +117,65 @@ 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<String>();
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<String> template = Messages.getList("preview.scrap_chance");
if (template.isEmpty()) {
return;
}
Map<String, String> values = Map.of(
"item", itemName(stack),
"chance", formatChance(output.returnChance()),
"amount", Integer.toString(output.baseAmount()));
var meta = stack.getItemMeta();
List<String> lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>();
for (String line : template) {
// 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);
}

/** 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) + "%";
}

}
32 changes: 25 additions & 7 deletions src/main/resources/messages.yml
Original file line number Diff line number Diff line change
@@ -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%)"
57 changes: 55 additions & 2 deletions src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -36,10 +39,60 @@ 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 %amount% $1 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% &#00FF00%chance% %amount%\"").toFile());
manager.refreshPreview(player, session, escrow);
var shown = player.getOpenInventory().getTopInventory().getItem(slot);
assertEquals(64, shown.getAmount());
// 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);
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);
Expand Down
19 changes: 19 additions & 0 deletions src/test/java/net/tfminecraft/recycler/LoadersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading