diff --git a/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java b/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java index cc0de8b..49d04eb 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java @@ -29,6 +29,7 @@ import net.tfminecraft.advancedcrafting.objects.crafting.CraftingStation; import net.tfminecraft.advancedcrafting.utils.RevisionTracker; import net.tfminecraft.advancedcrafting.lifecycle.CraftLifecycle; +import net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes; import net.tfminecraft.advancedcrafting.lifecycle.PlayerAlloyForgeTracker; public class AdvancedCrafting extends JavaPlugin{ @@ -68,6 +69,7 @@ public void onEnable() { revisionTracker.load(getDataFolder()); loadConfigs(); CraftLifecycle.init(new PlayerAlloyForgeTracker(new File(getDataFolder(), "data"))); + KnownAlloyRecipes.init(new KnownAlloyRecipes(new File(getDataFolder(), "data"))); registerListeners(); craftRefreshListener.start(this); getCommand(commandManager.cmd1).setExecutor(commandManager); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/KnownAlloyRecipes.java b/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/KnownAlloyRecipes.java new file mode 100644 index 0000000..545623f --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/KnownAlloyRecipes.java @@ -0,0 +1,159 @@ +package net.tfminecraft.advancedcrafting.lifecycle; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.json.simple.JSONArray; +import org.json.simple.parser.JSONParser; + +import net.tfminecraft.advancedcrafting.AdvancedCrafting; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.data.AlloyRecipe; + +/** + * The alloy recipes each account has forged, as combo keys ({@code base|catalyst,catalyst}). + * A combo never changes when its alloy is named or renamed and is never reused, unlike alloy ids. + * Files live in {@code data/known-alloy-recipes/.json}. A player without a file yet starts + * from the alloys {@link PlayerAlloyForgeTracker} has on record for them. + */ +public final class KnownAlloyRecipes { + private static KnownAlloyRecipes instance; + + private final File folder; + private final Map> cache = new HashMap<>(); + /** Accounts whose file could not be read; it is never overwritten this session. */ + private final Set unreadable = new HashSet<>(); + + public KnownAlloyRecipes(File dataFolder) { + this.folder = new File(dataFolder, "known-alloy-recipes"); + if (!folder.exists()) { + folder.mkdirs(); + } + } + + public static void init(KnownAlloyRecipes known) { + instance = known; + } + + public static KnownAlloyRecipes get() { + return instance; + } + + /** Called for every successful forge (not scrap). No-op before the plugin has set an instance. */ + public static void learn(UUID playerUuid, AlloyRecipe recipe) { + if (instance != null && playerUuid != null && recipe != null) { + instance.add(playerUuid, recipe.comboKey()); + } + } + + public boolean knows(UUID playerUuid, Alloy alloy) { + if (playerUuid == null || alloy == null || alloy.getData() == null) { + return false; + } + AlloyRecipe recipe = alloy.getData().getRecipe(); + return recipe != null && load(playerUuid).contains(recipe.comboKey()); + } + + private void add(UUID playerUuid, String comboKey) { + Set known = load(playerUuid); + if (known.add(comboKey) && !save(playerUuid, known)) { + // Not on disk, so the next forge of this combo tries again. + known.remove(comboKey); + } + } + + private Set load(UUID playerUuid) { + Set known = cache.get(playerUuid); + if (known != null) { + return known; + } + known = new HashSet<>(); + File file = file(playerUuid); + if (file.exists()) { + try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + Object parsed = new JSONParser().parse(reader); + if (parsed instanceof JSONArray array) { + for (Object entry : array) { + if (entry != null && !entry.toString().isBlank()) { + known.add(entry.toString().trim().toLowerCase(Locale.ROOT)); + } + } + } + } catch (Exception ex) { + // Leave the file for staff to repair; this session works from memory only. + AdvancedCrafting.plugin.getLogger().warning("AC: could not read " + file.getPath() + ", leaving it unchanged: " + + ex.getMessage()); + unreadable.add(playerUuid); + cache.put(playerUuid, known); + return known; + } + } else if (seedFromForgeHistory(playerUuid, known)) { + save(playerUuid, known); + } + cache.put(playerUuid, known); + return known; + } + + /** Recipes of the alloys this player forged before recipes were recorded. */ + private boolean seedFromForgeHistory(UUID playerUuid, Set known) { + PlayerAlloyForgeTracker tracker = CraftLifecycle.getTracker(); + if (tracker == null) { + return false; + } + for (String alloyId : tracker.getForgedIds(playerUuid)) { + Alloy alloy = AlloyManager.getAlloyById(alloyId); + if (alloy != null && alloy.getData() != null && alloy.getData().getRecipe() != null) { + known.add(alloy.getData().getRecipe().comboKey()); + } + } + return !known.isEmpty(); + } + + /** Writes a temp file and moves it over the old one, so a crash can't leave half a file. */ + @SuppressWarnings("unchecked") + private boolean save(UUID playerUuid, Set known) { + if (unreadable.contains(playerUuid)) { + return true; + } + List sorted = new ArrayList<>(known); + sorted.sort(null); + JSONArray array = new JSONArray(); + array.addAll(sorted); + Path target = file(playerUuid).toPath(); + Path temp = target.resolveSibling(target.getFileName() + ".tmp"); + try { + Files.createDirectories(folder.toPath()); + Files.writeString(temp, array.toJSONString(), StandardCharsets.UTF_8); + try { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + return true; + } catch (IOException ex) { + AdvancedCrafting.plugin.getLogger().warning("AC: could not save known alloy recipes for " + playerUuid + + ": " + ex.getMessage()); + return false; + } + } + + private File file(UUID playerUuid) { + return new File(folder, playerUuid + ".json"); + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/PlayerAlloyForgeTracker.java b/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/PlayerAlloyForgeTracker.java index 71b370e..79191a9 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/PlayerAlloyForgeTracker.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/lifecycle/PlayerAlloyForgeTracker.java @@ -46,6 +46,14 @@ public boolean recordForge(UUID playerUuid, String alloyId) { return true; } + /** The alloy ids this account has forged; a read-only copy. */ + public Set getForgedIds(UUID playerUuid) { + if (playerUuid == null) { + return Set.of(); + } + return Set.copyOf(loadForgedIds(playerUuid)); + } + private Set loadForgedIds(UUID playerUuid) { Set cached = CACHE.get(playerUuid); if (cached != null) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyRecipeCommand.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyRecipeCommand.java new file mode 100644 index 0000000..a15a96a --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyRecipeCommand.java @@ -0,0 +1,142 @@ +package net.tfminecraft.advancedcrafting.managers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; + +import org.apache.commons.lang.WordUtils; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.tfminecraft.advancedcrafting.AdvancedCrafting; +import net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes; +import net.tfminecraft.advancedcrafting.loaders.IngredientLoader; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.data.AlloyRecipe; +import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; +import net.tfminecraft.advancedcrafting.utils.AcItemTags; + +/** + * {@code /alloy recipes} and {@code /alloy recipe [name]}: the base and catalysts of the alloys a + * player has forged. Only the ingredients are shown, never hits, stats or forge odds. An alloy the + * player has not forged gets the same reply whether or not it exists, so names can't be probed. + */ +final class AlloyRecipeCommand { + private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacySection(); + + /** {@code /alloy recipes}: every alloy this account has forged, each name clickable. */ + void list(Player p) { + List known = knownAlloys(p); + if (known.isEmpty()) { + p.sendMessage("§7You haven't forged any alloys yet. Forge one to keep its recipe here."); + return; + } + p.sendMessage("§6Alloys you have forged §7(" + known.size() + ")§6, click one for its recipe:"); + Component line = Component.empty(); + for (int i = 0; i < known.size(); i++) { + Alloy alloy = known.get(i); + if (i > 0) { + line = line.append(Component.text(", ", NamedTextColor.GRAY)); + } + line = line.append(LEGACY.deserialize(alloy.getName()) + .clickEvent(ClickEvent.runCommand("/alloy recipe " + alloy.getId())) + .hoverEvent(HoverEvent.showText(Component.text("Show the recipe", NamedTextColor.GRAY)))); + } + p.sendMessage(line); + } + + /** {@code /alloy recipe [name]}: the named alloy, or the alloy in the main hand. */ + void show(Player p, String[] args) { + if (args.length < 2) { + showHeld(p); + return; + } + String typed = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + String id = typed.replace(' ', '_').toLowerCase(Locale.ROOT); + Alloy alloy = AlloyManager.getAlloyById(id); + if (alloy == null || !knows(p, alloy)) { + p.sendMessage("§cYou don't know the recipe for §f" + typed + "§c."); + return; + } + sendRecipe(p, alloy); + } + + private void showHeld(Player p) { + ItemStack held = p.getInventory().getItemInMainHand(); + if (AcItemTags.getKind(held) != AcItemTags.Kind.ALLOY) { + p.sendMessage("§cHold an alloy, or use §f/alloy recipe §c. §f/alloy recipes §clists the ones you know."); + return; + } + Alloy alloy = AlloyManager.getAlloyById(AcItemTags.getId(held)); + if (alloy == null || !knows(p, alloy)) { + String name = held.getItemMeta().hasDisplayName() ? held.getItemMeta().getDisplayName() : "this alloy"; + p.sendMessage("§cYou don't know the recipe for §f" + name + "§c. Forge it yourself to learn it."); + return; + } + sendRecipe(p, alloy); + } + + private void sendRecipe(Player p, Alloy alloy) { + // knows() already required a recipe. + AlloyRecipe recipe = alloy.getData().getRecipe(); + // The forge reads this index, so a combo that now makes something else must not be shown. + String result = AdvancedCrafting.getAlloyRecipeStore().getResultByCombo(recipe.comboKey()); + if (!alloy.getId().equalsIgnoreCase(result)) { + p.sendMessage("§cThat recipe no longer makes §f" + alloy.getName() + "§c."); + return; + } + p.sendMessage("§6Recipe for §r" + alloy.getName()); + p.sendMessage("§7Base: §r" + ingredientName(recipe.getBaseId())); + List catalysts = new ArrayList<>(); + for (String id : recipe.getCatalystIds()) { + catalysts.add(ingredientName(id)); + } + p.sendMessage("§7Catalysts: §r" + (catalysts.isEmpty() ? "§fnone" : String.join("§7, §r", catalysts))); + p.sendMessage("§8Add the base to an alloy forge first, then the catalysts in any order."); + } + + /** Tab completion for {@code /alloy recipe}: only alloys this player has forged. */ + List completions(Player p) { + List ids = new ArrayList<>(); + for (Alloy alloy : knownAlloys(p)) { + ids.add(alloy.getId()); + } + return ids; + } + + private List knownAlloys(Player p) { + List known = new ArrayList<>(); + for (String id : new TreeSet<>(AlloyManager.getAlloyIds())) { + Alloy alloy = AlloyManager.getAlloyById(id); + if (knows(p, alloy)) { + known.add(alloy); + } + } + return known; + } + + private boolean knows(Player p, Alloy alloy) { + KnownAlloyRecipes known = KnownAlloyRecipes.get(); + return known != null && known.knows(p.getUniqueId(), alloy); + } + + /** The ingredient's item name with its colour, or its id in title case if the item can't be built. */ + static String ingredientName(String id) { + Ingredient ingredient = IngredientLoader.getByString(id); + if (ingredient != null) { + try { + // Ingredient.buildTo always gives the item a display name. + return ingredient.build().getItemMeta().getDisplayName(); + } catch (RuntimeException ex) { + // The item source (MMOItems, ItemsAdder) may be missing; fall back to the id. + } + } + return "§f" + WordUtils.capitalize(id.replace('_', ' ')); + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java index c29433f..652492b 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java @@ -30,6 +30,7 @@ public class CommandManager implements Listener, CommandExecutor, TabCompleter { private final EquipmentGiveCommand equipmentGive = new EquipmentGiveCommand(); + private final AlloyRecipeCommand alloyRecipes = new AlloyRecipeCommand(); public String cmd1 = "ac"; public String cmd2 = "alloy"; @@ -38,17 +39,29 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String if (cmd.getName().equalsIgnoreCase(cmd1)) { return handleAcCommand(sender, args); } - if (sender instanceof Player p) { - if (cmd.getName().equalsIgnoreCase(cmd2) && args.length >= 1 && args[0].equalsIgnoreCase("name")) { + if (!cmd.getName().equalsIgnoreCase(cmd2) || args.length < 1) { + return false; + } + String sub = args[0].toLowerCase(Locale.ROOT); + if (!sub.equals("name") && !sub.equals("recipe") && !sub.equals("recipes")) { + return false; + } + if (!(sender instanceof Player p)) { + sender.sendMessage("§cPlayers only."); + return true; + } + switch (sub) { + case "recipes" -> alloyRecipes.list(p); + case "recipe" -> alloyRecipes.show(p, args); + default -> { if (args.length < 2) { p.sendMessage("§cNo name specified, format: /alloy name newname"); - return false; + return true; } AdvancedCrafting.getAlloyManager().nameAlloy(p, args[1]); - return true; } } - return false; + return true; } private boolean handleAcCommand(CommandSender sender, String[] args) { @@ -165,7 +178,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe return onAcTabComplete(sender, args); } if (cmd.getName().equalsIgnoreCase(cmd2)) { - return onAlloyTabComplete(args); + return onAlloyTabComplete(sender, args); } return List.of(); } @@ -229,12 +242,15 @@ private List onAcTabComplete(CommandSender sender, String[] args) { return List.of(); } - private List onAlloyTabComplete(String[] args) { + private List onAlloyTabComplete(CommandSender sender, String[] args) { if (args.length == 0) { - return filterPrefix("", "name"); + return filterPrefix("", "name", "recipe", "recipes"); } if (args.length == 1) { - return filterPrefix(args[0], "name"); + return filterPrefix(args[0], "name", "recipe", "recipes"); + } + if (args.length == 2 && args[0].equalsIgnoreCase("recipe") && sender instanceof Player p) { + return filterPrefix(args[1], alloyRecipes.completions(p)); } return List.of(); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java index d0aeb29..fd9b962 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java @@ -23,6 +23,7 @@ import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; import net.tfminecraft.advancedcrafting.lifecycle.CraftLifecycle; +import net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes; public class AlloyForger { private static final String GEMSTONE_PATH_PREFIX = "m.gemstones."; @@ -104,6 +105,7 @@ public NamableAlloy forge(Player p, int i) { loc.getWorld().dropItem(loc, template); return null; } + KnownAlloyRecipes.learn(p.getUniqueId(), AlloyRecipe.fromStation(station)); ItemStack item = loc.getWorld().dropItem(loc, a.build()).getItemStack(); if (isNew) { return new NamableAlloy(a, item); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyRecipe.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyRecipe.java index 28fcc0c..9ab4c23 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyRecipe.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyRecipe.java @@ -5,6 +5,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Objects; import net.tfminecraft.advancedcrafting.objects.alloys.AlloyStation; @@ -15,10 +16,10 @@ public class AlloyRecipe { private final List catalystIds; public AlloyRecipe(String baseId, List catalystIds) { - this.baseId = baseId.toLowerCase(); + this.baseId = baseId.toLowerCase(Locale.ROOT); List sorted = new ArrayList<>(catalystIds); for (int i = 0; i < sorted.size(); i++) { - sorted.set(i, sorted.get(i).toLowerCase()); + sorted.set(i, sorted.get(i).toLowerCase(Locale.ROOT)); } Collections.sort(sorted); this.catalystIds = Collections.unmodifiableList(sorted); @@ -69,7 +70,7 @@ public static AlloyRecipe fromComboKey(String comboKey) { if (comboKey == null || comboKey.isBlank()) { return null; } - String normalized = comboKey.toLowerCase(); + String normalized = comboKey.toLowerCase(Locale.ROOT); int pipe = normalized.indexOf('|'); if (pipe < 0) { return new AlloyRecipe(normalized, List.of()); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0f7cefa..e8ab1df 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -12,8 +12,8 @@ commands: usage: / description: AdvancedCrafting Command alloy: - usage: / - description: Alloy Command + usage: "/ name | recipes | recipe [name]" + description: Name a new alloy, or see the recipes of alloys you have forged permissions: advancedcrafting.admin: diff --git a/src/test/java/net/tfminecraft/advancedcrafting/AlloyRecipeCommandTest.java b/src/test/java/net/tfminecraft/advancedcrafting/AlloyRecipeCommandTest.java new file mode 100644 index 0000000..7854b15 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/AlloyRecipeCommandTest.java @@ -0,0 +1,375 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.tfminecraft.advancedcrafting.database.AlloyRecipeStore; +import net.tfminecraft.advancedcrafting.lifecycle.CraftLifecycle; +import net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes; +import net.tfminecraft.advancedcrafting.lifecycle.PlayerAlloyForgeTracker; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.managers.CommandManager; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.data.AlloyData; +import net.tfminecraft.advancedcrafting.objects.data.AlloyRecipe; +import net.tfminecraft.advancedcrafting.utils.PDCKeys; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AlloyRecipeCommandTest extends CoverageSupport { + CommandManager manager; + PlayerAlloyForgeTracker tracker; + KnownAlloyRecipes known; + AlloyRecipeStore store; + + @BeforeEach + void setupStores() throws Exception { + manager = new CommandManager(); + tracker = new PlayerAlloyForgeTracker(temp.toFile()); + CraftLifecycle.init(tracker); + known = new KnownAlloyRecipes(temp.toFile()); + KnownAlloyRecipes.init(known); + store = new AlloyRecipeStore(temp.resolve("data/alloy-recipes").toFile()); + var field = AdvancedCrafting.class.getDeclaredField("alloyRecipeStore"); + field.setAccessible(true); + field.set(plugin, store); + } + + @AfterEach + void clearStores() { + CraftLifecycle.init(null); + KnownAlloyRecipes.init(null); + } + + Command cmd() { + var c = mock(Command.class); + when(c.getName()).thenReturn("alloy"); + return c; + } + + boolean alloy(CommandSender sender, String... args) { + return manager.onCommand(sender, cmd(), "alloy", args); + } + + /** An alloy whose combo the forge index maps to it, as on a live server. */ + Alloy addAlloy(String id, String name, AlloyRecipe recipe) { + var alloy = mock(Alloy.class); + var data = mock(AlloyData.class); + when(alloy.getId()).thenReturn(id); + when(alloy.getName()).thenReturn(name); + when(alloy.getData()).thenReturn(data); + when(data.getRecipe()).thenReturn(recipe); + AlloyManager.addAlloy(alloy); + if (recipe != null) store.upsert(recipe, id); + return alloy; + } + + static AlloyRecipe combo(String base, String... catalysts) { + return new AlloyRecipe(base, List.of(catalysts)); + } + + ItemStack alloyItem(String id, String displayName) { + var item = new ItemStack(Material.IRON_INGOT); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.alloyId(), PersistentDataType.STRING, id); + if (displayName != null) meta.setDisplayName(displayName); + item.setItemMeta(meta); + return item; + } + + List drain(PlayerMock p) { + var out = new ArrayList(); + String line; + while ((line = p.nextMessage()) != null) out.add(line); + return out; + } + + static void collectClicks(Component component, List out) { + ClickEvent click = component.clickEvent(); + if (click != null) out.add(click.value()); + for (Component child : component.children()) collectClicks(child, out); + } + + @Test + void listShowsOnlyAlloysWhoseRecipeThePlayerForged() { + var p = server.addPlayer("Smith"); + assertTrue(alloy(p, "recipes")); + assertTrue(p.nextMessage().contains("haven't forged any alloys")); + + var bronze = combo("copper_ingot", "tin_ingot"); + var darksteel = combo("steel_ingot", "coal"); + addAlloy("bronze", "§6Bronze", bronze); + addAlloy("darksteel", "§8Darksteel", darksteel); + addAlloy("secret", "§5Secret", combo("iron_ingot", "coal")); + addAlloy("lost", "§7Lost", null); + KnownAlloyRecipes.learn(p.getUniqueId(), darksteel); + KnownAlloyRecipes.learn(p.getUniqueId(), bronze); + KnownAlloyRecipes.learn(p.getUniqueId(), combo("gone_ingot", "coal")); + + assertTrue(alloy(p, "RECIPES")); + assertTrue(p.nextMessage().contains("(2)")); + Component line = p.nextComponentMessage(); + String plain = PlainTextComponentSerializer.plainText().serialize(line); + assertEquals("Bronze, Darksteel", plain); + var clicks = new ArrayList(); + collectClicks(line, clicks); + assertEquals(List.of("/alloy recipe bronze", "/alloy recipe darksteel"), clicks); + } + + @Test + void recipeShowsBaseAndCatalystsOnlyToTheForger() throws Exception { + var p = server.addPlayer("Forger"); + var other = server.addPlayer("Stranger"); + ingredient("steel_ingot", ""); + ingredient("broken_gem", ""); + when(items.getCreator().getItemFromPath("v.iron_ingot")) + .thenAnswer(i -> new ItemStack(Material.IRON_INGOT)) + .thenThrow(new IllegalStateException("no MMOItems")); + var darkSteel = combo("steel_ingot", "broken_gem", "black_coal"); + addAlloy("dark_steel", "§8Dark Steel", darkSteel); + KnownAlloyRecipes.learn(p.getUniqueId(), darkSteel); + + assertTrue(alloy(p, "recipe", "Dark", "Steel")); + var lines = drain(p); + assertEquals(4, lines.size(), lines.toString()); + assertTrue(lines.get(0).contains("Recipe for") && lines.get(0).contains("Dark Steel")); + assertTrue(lines.get(1).contains("Base:") && lines.get(1).contains("Iron Ingot"), lines.get(1)); + assertTrue( + lines.get(2).contains("Black Coal") && lines.get(2).contains("Broken Gem"), lines.get(2)); + assertTrue(lines.get(3).contains("any order")); + String shown = String.join("\n", lines); + assertFalse(shown.contains("%") || shown.toLowerCase().contains("hit"), shown); + + assertTrue(alloy(other, "recipe", "dark_steel")); + assertEquals(List.of("§cYou don't know the recipe for §fdark_steel§c."), drain(other)); + assertTrue(alloy(other, "recipe", "No", "Such", "Alloy")); + assertEquals(List.of("§cYou don't know the recipe for §fNo Such Alloy§c."), drain(other)); + } + + @Test + void recipeHandlesMissingRecipeNoCatalystsAndStaleIndexes() { + var p = server.addPlayer("Legacy"); + var plain = combo("iron_ingot"); + addAlloy("plain", "§7Plain", plain); + KnownAlloyRecipes.learn(p.getUniqueId(), plain); + assertTrue(alloy(p, "recipe", "plain")); + var lines = drain(p); + assertTrue(lines.get(1).contains("Iron Ingot")); + assertTrue(lines.get(2).endsWith("none")); + + store.upsert(plain, "scrap"); + assertTrue(alloy(p, "recipe", "plain")); + assertTrue(p.nextMessage().contains("no longer makes")); + store.deleteByCombo(plain.comboKey()); + assertTrue(alloy(p, "recipe", "plain")); + assertTrue(p.nextMessage().contains("no longer makes")); + + // A recipe recorded for the player whose alloy file later lost its recipe. + var old = addAlloy("old", "§7Old", combo("tin_ingot", "coal")); + KnownAlloyRecipes.learn(p.getUniqueId(), combo("tin_ingot", "coal")); + assertTrue(alloy(p, "recipe", "old")); + assertTrue(drain(p).get(0).contains("Recipe for")); + when(old.getData().getRecipe()).thenReturn(null); + assertTrue(alloy(p, "recipe", "old")); + assertTrue(p.nextMessage().contains("don't know")); + } + + @Test + void heldAlloyIsShownOnlyWhenForged() { + var p = server.addPlayer("Holder"); + var bronze = combo("copper_ingot", "tin_ingot"); + addAlloy("bronze", "§6Bronze", bronze); + + assertTrue(alloy(p, "recipe")); + assertTrue(p.nextMessage().contains("Hold an alloy")); + + p.getInventory().setItemInMainHand(alloyItem("bronze", "§6Bronze")); + assertTrue(alloy(p, "recipe")); + String refusal = p.nextMessage(); + assertTrue(refusal.contains("You don't know the recipe for"), refusal); + assertTrue(refusal.contains("§6Bronze") && refusal.contains("Forge it yourself"), refusal); + + p.getInventory().setItemInMainHand(alloyItem("unloaded", null)); + assertTrue(alloy(p, "recipe")); + assertTrue(p.nextMessage().contains("this alloy")); + + KnownAlloyRecipes.learn(p.getUniqueId(), bronze); + p.getInventory().setItemInMainHand(alloyItem("bronze", "§6Bronze")); + assertTrue(alloy(p, "recipe")); + var lines = drain(p); + assertTrue(lines.get(0).contains("Bronze")); + assertTrue(lines.get(1).contains("Copper Ingot")); + assertTrue(lines.get(2).contains("Tin Ingot")); + } + + @Test + void consoleAndMissingStoreAreHandled() { + var console = mock(CommandSender.class); + assertTrue(alloy(console, "recipes")); + assertTrue(alloy(console, "recipe", "bronze")); + assertTrue(alloy(console, "name", "x")); + verify(console, times(3)).sendMessage("§cPlayers only."); + assertFalse(alloy(console, "other")); + assertFalse(alloy(console)); + + var p = server.addPlayer("NoStore"); + var bronze = combo("copper_ingot", "tin_ingot"); + addAlloy("bronze", "§6Bronze", bronze); + KnownAlloyRecipes.learn(p.getUniqueId(), bronze); + KnownAlloyRecipes.init(null); + KnownAlloyRecipes.learn(p.getUniqueId(), bronze); + assertTrue(alloy(p, "recipes")); + assertTrue(p.nextMessage().contains("haven't forged")); + assertTrue(alloy(p, "recipe", "bronze")); + assertTrue(p.nextMessage().contains("don't know")); + assertTrue(manager.onTabComplete(p, cmd(), "alloy", new String[] {"recipe", ""}).isEmpty()); + } + + @Test + void tabCompletionOffersOnlyOwnAlloys() { + var p = server.addPlayer("Tabber"); + var bronze = combo("copper_ingot", "tin_ingot"); + addAlloy("bronze", "§6Bronze", bronze); + addAlloy("brass", "§eBrass", combo("copper_ingot", "zinc")); + KnownAlloyRecipes.learn(p.getUniqueId(), bronze); + assertEquals( + List.of("recipe", "recipes"), + manager.onTabComplete(p, cmd(), "alloy", new String[] {"rec"})); + assertEquals( + List.of("bronze"), + manager.onTabComplete(p, cmd(), "alloy", new String[] {"recipe", "br"})); + assertTrue(manager.onTabComplete(p, cmd(), "alloy", new String[] {"recipes", ""}).isEmpty()); + assertTrue( + manager.onTabComplete(p, cmd(), "alloy", new String[] {"recipe", "bronze", ""}).isEmpty()); + var console = mock(CommandSender.class); + assertTrue( + manager.onTabComplete(console, cmd(), "alloy", new String[] {"recipe", ""}).isEmpty()); + } + + @Test + void knowledgeSurvivesRenamesAndIsSeededFromForgeHistory() throws Exception { + var smith = UUID.randomUUID(); + var bronze = combo("copper_ingot", "tin_ingot"); + var brass = combo("copper_ingot", "zinc"); + var bronzeAlloy = addAlloy("ferrum_vex", "§6Ferrum Vex", bronze); + addAlloy("brass", "§eBrass", brass); + addAlloy("no_recipe", "§7No Recipe", null); + var noData = mock(Alloy.class); + when(noData.getId()).thenReturn("no_data"); + AlloyManager.addAlloy(noData); + tracker.recordForge(smith, "no_data"); + assertTrue(tracker.getForgedIds(null).isEmpty()); + tracker.recordForge(smith, "brass"); + tracker.recordForge(smith, "no_recipe"); + tracker.recordForge(smith, "deleted"); + KnownAlloyRecipes.learn(smith, bronze); + KnownAlloyRecipes.learn(smith, bronze); + + // Seeded from the forge history on first use, then the new forge is added. + var file = temp.resolve("known-alloy-recipes/" + smith + ".json"); + assertEquals("[\"copper_ingot|tin_ingot\",\"copper_ingot|zinc\"]", Files.readString(file)); + + // Naming changes the id; the combo still matches. + when(bronzeAlloy.getId()).thenReturn("bright_bronze"); + assertTrue(known.knows(smith, bronzeAlloy)); + + // A fresh instance reads the file back instead of re-seeding. + tracker.recordForge(smith, "other"); + var reread = new KnownAlloyRecipes(temp.toFile()); + assertTrue(reread.knows(smith, AlloyManager.getAlloyById("brass"))); + + // Nothing on record: no file is written. + var newcomer = UUID.randomUUID(); + assertFalse(known.knows(newcomer, bronzeAlloy)); + assertFalse(Files.exists(temp.resolve("known-alloy-recipes/" + newcomer + ".json"))); + CraftLifecycle.init(null); + assertFalse(new KnownAlloyRecipes(temp.toFile()).knows(newcomer, bronzeAlloy)); + } + + @Test + void knowledgeGuardsBadInputUnreadableFilesAndFailedSaves() throws Exception { + var id = UUID.randomUUID(); + var bronze = combo("copper_ingot", "tin_ingot"); + var alloy = addAlloy("bronze", "§6Bronze", bronze); + var noData = mock(Alloy.class); + assertFalse(known.knows(null, alloy)); + assertFalse(known.knows(id, null)); + assertFalse(known.knows(id, noData)); + KnownAlloyRecipes.learn(null, bronze); + KnownAlloyRecipes.learn(id, null); + assertFalse(Files.exists(temp.resolve("known-alloy-recipes/" + id + ".json"))); + + var broken = UUID.randomUUID(); + var brokenFile = temp.resolve("known-alloy-recipes/" + broken + ".json"); + Files.writeString(brokenFile, "{not json"); + assertFalse(known.knows(broken, alloy)); + KnownAlloyRecipes.learn(broken, bronze); + assertTrue(known.knows(broken, alloy)); + assertEquals("{not json", Files.readString(brokenFile)); + + var odd = UUID.randomUUID(); + Files.writeString( + temp.resolve("known-alloy-recipes/" + odd + ".json"), + "[\"COPPER_INGOT|TIN_INGOT\", \" \", null]"); + assertTrue(known.knows(odd, alloy)); + var notArray = UUID.randomUUID(); + Files.writeString(temp.resolve("known-alloy-recipes/" + notArray + ".json"), "{}"); + assertFalse(known.knows(notArray, alloy)); + + // The folder is recreated if it was removed. + var gone = new KnownAlloyRecipes(temp.resolve("elsewhere").toFile()); + Files.delete(temp.resolve("elsewhere/known-alloy-recipes")); + KnownAlloyRecipes.init(gone); + KnownAlloyRecipes.learn(id, bronze); + assertTrue(gone.knows(id, alloy)); + assertTrue(Files.exists(temp.resolve("elsewhere/known-alloy-recipes/" + id + ".json"))); + assertSame(gone, KnownAlloyRecipes.get()); + + // A save that fails is not kept, so the next forge tries again. + var blocked = UUID.randomUUID(); + var blockedPath = temp.resolve("known-alloy-recipes/" + blocked + ".json"); + var tempPath = temp.resolve("known-alloy-recipes/" + blocked + ".json.tmp"); + Files.createDirectories(tempPath.resolve("inside")); + KnownAlloyRecipes.init(known); + KnownAlloyRecipes.learn(blocked, bronze); + assertFalse(known.knows(blocked, alloy)); + assertFalse(Files.exists(blockedPath)); + Files.delete(tempPath.resolve("inside")); + Files.delete(tempPath); + KnownAlloyRecipes.learn(blocked, bronze); + assertTrue(known.knows(blocked, alloy)); + assertEquals("[\"copper_ingot|tin_ingot\"]", Files.readString(blockedPath)); + + // A file system without atomic moves still gets the file, with a plain replace. + var plainMove = UUID.randomUUID(); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when( + () -> + Files.move( + any(java.nio.file.Path.class), + any(java.nio.file.Path.class), + eq(java.nio.file.StandardCopyOption.REPLACE_EXISTING), + eq(java.nio.file.StandardCopyOption.ATOMIC_MOVE))) + .thenThrow(new java.nio.file.AtomicMoveNotSupportedException("a", "b", "test")); + KnownAlloyRecipes.learn(plainMove, bronze); + } + assertEquals( + "[\"copper_ingot|tin_ingot\"]", + Files.readString(temp.resolve("known-alloy-recipes/" + plainMove + ".json"))); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java index 41f9d73..099861b 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java @@ -77,7 +77,7 @@ void dispatchRequiresAdminAndPlayerWhereApplicable() throws Exception { verify(crafting).setAdminCraftPending(p, 100); assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {})); assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"other"})); - assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"name"})); + assertTrue(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"name"})); assertTrue(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"name", "steel"})); verify(alloys).nameAlloy(p, "steel"); } @@ -191,7 +191,9 @@ void tabCompletionFiltersCommandsIdsAndOnlinePlayers() { entry.getValue(), manager.onTabComplete(p, cmd("ac"), "ac", entry.getKey().toArray(String[]::new)), entry.getKey().toString()); - assertEquals(List.of("name"), manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {})); + assertEquals( + List.of("name", "recipe", "recipes"), + manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {})); assertEquals( List.of("name"), manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {"n"})); assertTrue( diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java index 3806524..94685bd 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java @@ -152,6 +152,43 @@ void forgeReturnsScrapKnownAlloysNewDiscoveriesAndRecoversStaleIndexes() throws assertNull(forger(station, 0).forge(p, 101)); } + @Test + void successfulForgesTeachTheRecipeButScrapDoesNot() throws Exception { + var p = server.addPlayer(); + var loc = new Location(server.addSimpleWorld("world"), 0, 1, 2); + var station = new AlloyStation(loc); + station.addIngredient(ingredient("iron", "base: true")); + station.addIngredient(ingredient("copper", "")); + var knownRecipes = new net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes(temp.toFile()); + net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes.init(knownRecipes); + var alloy = mock(Alloy.class, RETURNS_DEEP_STUBS); + when(alloy.getId()).thenReturn("known"); + when(alloy.getData().getRecipe()).thenReturn(AlloyRecipe.fromStation(station)); + when(alloy.build()) + .thenAnswer(i -> new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_INGOT)); + AlloyManager.addAlloy(alloy); + try { + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("scrap"))) { + Cache.scrap = "MATERIAL.SCRAP"; + when(net.Indyuce.mmoitems.MMOItems.plugin.getItems().getMMOItem(any(), eq("SCRAP")).newBuilder().build()) + .thenAnswer(i -> new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_NUGGET)); + assertNull(forger(station, 0).forge(p)); + } + assertFalse(knownRecipes.knows(p.getUniqueId(), alloy)); + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("known"))) { + assertNull(forger(station, 0).forge(p)); + } + assertTrue(knownRecipes.knows(p.getUniqueId(), alloy)); + } finally { + net.tfminecraft.advancedcrafting.lifecycle.KnownAlloyRecipes.init(null); + AlloyManager.removeAlloy("known"); + } + } + @Test void deterministicMergeCoversGuaranteedInheritanceCapsAndProtectedStats() throws Exception { Cache.maxFactor = 1.5;