diff --git a/README.md b/README.md index 0b2ac65..2b496a5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,26 @@ finished item. - **Profession progression** — connect recipes and materials to profession access and experience rewards. +## Staff equipment grants + +`/ac give equipment [player] [quality]` +gives one finished item using a configured recipe and compatible main material. +For example, `/ac give equipment heavy_chestplate ingredient.steel_ingot Alex 100`. +Tab completion lists loaded recipes, compatible ingredients/alloys and online players. +Omit the player to give to yourself; console must specify an online player. +Quality defaults to 100 and accepts finite values from 0 to 100 (specify the player +before quality). Alloy IDs are the loaded discovery IDs, not display names. + +Secondary recipe ingredients use the lowest configured tier, then ingredient ID +alphabetically to break ties. Their stats and appearance participate normally. +The item retains normal stats, quality sockets, appearance, tier and crafting +provenance; no materials are consumed and no XP or activity rewards are granted. +Full inventories drop the item at the recipient's location. + +Set `give-equipment-permission` in `config.yml` and run `/ac reload` to change +access. Missing or blank settings default to `advancedcrafting.admin` (operators). +Grant a custom permission only to staff; it does not grant other admin commands. + ## Credits Originally authored by **Drefvelin**. diff --git a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java index 6444fe5..0f03bb6 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java @@ -14,6 +14,7 @@ public class Cache { public static String craftingStation; public static String alloyStation; public static String ingredientStation; + public static String giveEquipmentPermission = "advancedcrafting.admin"; public static double alloyForgeBaseSuccess = 2.0; public static double alloyForgeBonusPerSqrtValue = 4.0; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index 717ab00..1f8d352 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -29,6 +29,10 @@ public void load(File configFile) { } Cache.scrap = config.getString("scrap-path"); + Cache.giveEquipmentPermission = config.getString("give-equipment-permission", "advancedcrafting.admin").trim(); + if (Cache.giveEquipmentPermission.isEmpty()) { + Cache.giveEquipmentPermission = "advancedcrafting.admin"; + } Cache.craftingStation = config.getString("crafting-station", "v(ANVIL)"); Cache.alloyStation = config.getString("alloy-station", "v(BLAST_FURNACE)"); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java index b56812a..c29433f 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java @@ -29,6 +29,7 @@ import net.tfminecraft.advancedcrafting.utils.CraftStatRefresher.RefreshResult; public class CommandManager implements Listener, CommandExecutor, TabCompleter { + private final EquipmentGiveCommand equipmentGive = new EquipmentGiveCommand(); public String cmd1 = "ac"; public String cmd2 = "alloy"; @@ -51,6 +52,9 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } private boolean handleAcCommand(CommandSender sender, String[] args) { + if (args.length >= 2 && args[0].equalsIgnoreCase("give") && args[1].equalsIgnoreCase("equipment")) { + return equipmentGive.execute(sender, args); + } if (args.length >= 1 && args[0].equalsIgnoreCase("reload")) { if (!AdminPermissions.require(sender)) { return true; @@ -167,7 +171,19 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } private List onAcTabComplete(CommandSender sender, String[] args) { + if (args.length >= 2 && args[0].equalsIgnoreCase("give")) { + if (args.length == 2) { + List options = new ArrayList<>(); + if (sender.hasPermission(AdminPermissions.PERMISSION)) options.add("alloy"); + if (equipmentGive.hasPermission(sender)) options.add("equipment"); + return filterPrefix(args[1], options); + } + if (args[1].equalsIgnoreCase("equipment")) return equipmentGive.complete(sender, args); + } if (!sender.hasPermission(AdminPermissions.PERMISSION)) { + if (args.length <= 1 && equipmentGive.hasPermission(sender)) { + return filterPrefix(args.length == 0 ? "" : args[0], "give"); + } return List.of(); } if (args.length == 0) { @@ -181,9 +197,6 @@ private List onAcTabComplete(CommandSender sender, String[] args) { if (sub.equals("sync")) { return filterPrefix(args[1], "recipes"); } - if (sub.equals("give")) { - return filterPrefix(args[1], "alloy"); - } if (sub.equals("alloy")) { return filterPrefix(args[1], "info"); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/EquipmentGiveCommand.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/EquipmentGiveCommand.java new file mode 100644 index 0000000..7fc323f --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/EquipmentGiveCommand.java @@ -0,0 +1,132 @@ +package net.tfminecraft.advancedcrafting.managers; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; +import java.util.logging.Level; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.advancedcrafting.AdvancedCrafting; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.IngredientLoader; +import net.tfminecraft.advancedcrafting.loaders.RecipeLoader; +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingStation; +import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; + +/** Staff grants use the normal equipment builder, without crafting rewards or station state. */ +final class EquipmentGiveCommand { + boolean hasPermission(CommandSender sender) { + return sender.hasPermission(Cache.giveEquipmentPermission); + } + + boolean execute(CommandSender sender, String[] args) { + if (!hasPermission(sender)) { + sender.sendMessage("§cNo permission."); + return true; + } + if (args.length < 4 || args.length > 6) { + sender.sendMessage("§cUsage: /ac give equipment [player] [quality 0-100]"); + return true; + } + Player target; + if (args.length >= 5) { + target = Bukkit.getPlayerExact(args[4]); + if (target == null) { + sender.sendMessage("§cPlayer not found: §f" + args[4]); + return true; + } + } else if (sender instanceof Player player) { + target = player; + } else { + sender.sendMessage("§cSpecify an online player from console."); + return true; + } + double quality = 100; + if (args.length == 6) { + try { + quality = Double.parseDouble(args[5]); + } catch (NumberFormatException ex) { + sender.sendMessage("§cQuality must be a finite number from 0 to 100."); + return true; + } + if (!Double.isFinite(quality) || quality < 0 || quality > 100) { + sender.sendMessage("§cQuality must be a finite number from 0 to 100."); + return true; + } + } + CraftingRecipe recipe = RecipeLoader.getByString(args[2].toLowerCase(Locale.ROOT)); + if (recipe == null) { + sender.sendMessage("§cUnknown recipe: §f" + args[2]); + return true; + } + String material = args[3].toLowerCase(Locale.ROOT); + if (!materialsFor(recipe).contains(material)) { + sender.sendMessage("§cUnknown or incompatible base material/alloy: §f" + args[3]); + return true; + } + HashMap inputs = new HashMap<>(); + for (var entry : recipe.getRecipe().entrySet()) { + String key = material; + if (!entry.getKey().equalsIgnoreCase(recipe.getMainType())) { + Ingredient filler = IngredientLoader.get().stream() + .filter(i -> i.getIngredientData().getType().getId().equalsIgnoreCase(entry.getKey())) + .min(Comparator.comparingInt((Ingredient i) -> i.getIngredientData().getTier()) + .thenComparing(Ingredient::getId)).orElse(null); + if (filler == null) { + sender.sendMessage("§cNo configured ingredient for required type: §f" + entry.getKey()); + return true; + } + key = "ingredient." + filler.getId(); + } + inputs.put(key, entry.getValue()); + } + try { + CraftingStation station = new CraftingStation(target.getLocation(), recipe, inputs, new HashMap<>()); + ItemStack item = station.buildCompletedItem(target, quality); + for (ItemStack overflow : target.getInventory().addItem(item).values()) { + target.getWorld().dropItemNaturally(target.getLocation(), overflow); + } + } catch (RuntimeException ex) { + AdvancedCrafting.plugin.getLogger().log(Level.SEVERE, "Failed to give equipment for recipe " + recipe.getId(), ex); + sender.sendMessage("§cEquipment could not be built. Check the server log and recipe/template configuration."); + return true; + } + sender.sendMessage("§aGave §f" + recipe.getId() + "§a using §f" + material + "§a to §f" + target.getName() + "§a."); + return true; + } + + private List materialsFor(CraftingRecipe recipe) { + if (!recipe.getRecipe().containsKey(recipe.getMainType())) return List.of(); + Stream ingredients = IngredientLoader.get().stream() + .filter(i -> i.getIngredientData().getType().getId().equalsIgnoreCase(recipe.getMainType())) + .map(i -> "ingredient." + i.getId()); + Stream alloys = AlloyManager.getAlloyIds().stream() + .filter(id -> AlloyManager.getAlloyById(id).getData().getType().getId().equalsIgnoreCase(recipe.getMainType())) + .map(id -> "alloy." + id); + return Stream.concat(ingredients, alloys).sorted().toList(); + } + + List complete(CommandSender sender, String[] args) { + if (!hasPermission(sender)) return List.of(); + List options; + switch (args.length) { + case 3 -> options = RecipeLoader.get().keySet().stream().sorted().toList(); + case 4 -> { + CraftingRecipe recipe = RecipeLoader.getByString(args[2].toLowerCase(Locale.ROOT)); + options = recipe == null ? List.of() : materialsFor(recipe); + } + case 5 -> options = Bukkit.getOnlinePlayers().stream().map(Player::getName).sorted().toList(); + case 6 -> options = List.of("0", "25", "50", "75", "90", "100"); + default -> options = List.of(); + } + String prefix = args[args.length - 1].toLowerCase(Locale.ROOT); + return options.stream().filter(o -> o.toLowerCase(Locale.ROOT).startsWith(prefix)).toList(); + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java index e73d8d1..50b7378 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -385,6 +385,21 @@ private void warnOvershootHits(Player p) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private StationFeedback createItem(Player p, Double forcedQualityPercent) { + return createItem(p, forcedQualityPercent, true); + } + + /** Builds staff equipment without consuming inventory, dropping an item, paying XP or firing progression events. */ + public ItemStack buildCompletedItem(Player p, double qualityPercent) { + stats = CraftStatCalculator.compute(recipe, currentMaterials); + StationFeedback feedback = createItem(p, qualityPercent, false); + if (feedback != StationFeedback.SUCCESS) { + throw new IllegalStateException("Cannot build equipment: " + feedback); + } + return result; + } + + @SuppressWarnings("deprecation") + private StationFeedback createItem(Player p, Double forcedQualityPercent, boolean deliver) { if (!checkItems(p)) { return StationFeedback.LACKING_ITEMS; } @@ -496,8 +511,11 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent) { if (majorityTier > 0) { CraftTierLore.applyTierLine(finalItem, majorityTier); } - Location dropLoc = loc.clone().add(0, 1, 0); - dropLoc.getWorld().dropItem(dropLoc, finalItem); + this.result = finalItem; + if (deliver) { + Location dropLoc = loc.clone().add(0, 1, 0); + dropLoc.getWorld().dropItem(dropLoc, finalItem); + } if (forcedQualityPercent == null) { CraftLifecycle.fireItemCrafted(p, recipe.getId(), recipe.getCategoryId()); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ca3010e..33f360b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,6 +1,10 @@ scrap-path: crafting.scrap branding-tool: m.tools.skin_branding_tool +# Staff-only finished equipment command. Missing/blank values use advancedcrafting.admin. +# Grant a custom node only to staff; it does not grant the other admin commands. +give-equipment-permission: advancedcrafting.admin + # TLibs block path for each station (iaf(...) for ItemsAdder furniture). crafting-station: v(ANVIL) alloy-station: iaf(tfmc:alloy_forge) diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java index 73227ef..41f9d73 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java @@ -171,7 +171,7 @@ void tabCompletionFiltersCommandsIdsAndOnlinePlayers() { AlloyManager.addAlloy(alloy); Map, List> cases = new LinkedHashMap<>(); cases.put(List.of("sync", ""), List.of("recipes")); - cases.put(List.of("give", ""), List.of("alloy")); + cases.put(List.of("give", ""), List.of("alloy", "equipment")); cases.put(List.of("alloy", ""), List.of("info")); cases.put(List.of("craft", "1"), List.of("100")); cases.put(List.of("other", ""), List.of()); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/EquipmentGiveCommandTest.java b/src/test/java/net/tfminecraft/advancedcrafting/EquipmentGiveCommandTest.java new file mode 100644 index 0000000..2cfb992 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/EquipmentGiveCommandTest.java @@ -0,0 +1,189 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.*; +import io.lumine.mythic.lib.api.item.NBTItem; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.StringData; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.IngredientType; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.entity.Item; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class EquipmentGiveCommandTest extends CoverageSupport { + final CommandManager commands = new CommandManager(); + + Command command() { + Command command = mock(Command.class); + when(command.getName()).thenReturn("ac"); + return command; + } + + boolean run(CommandSender sender, String... tail) { + List args = new ArrayList<>(List.of("give", "equipment")); + args.addAll(Arrays.asList(tail)); + return commands.onCommand(sender, command(), "ac", args.toArray(String[]::new)); + } + + List complete(CommandSender sender, String... args) { + return commands.onTabComplete(sender, command(), "ac", args); + } + + @Test + void customPermissionIsRequiredForExecutionAndCompletionAndReloadsSafely() throws Exception { + var config = temp.resolve("config.yml"); + Files.writeString(config, "give-equipment-permission: ' staff.equipment '"); + new ConfigLoader().load(config.toFile()); + assertEquals("staff.equipment", Cache.giveEquipmentPermission); + var staff = mock(CommandSender.class); + assertTrue(run(staff)); + verify(staff).sendMessage(contains("No permission")); + assertEquals(List.of(), complete(staff, "give", "equipment", "")); + assertEquals(List.of(), complete(staff, "give", "")); + when(staff.hasPermission("staff.equipment")).thenReturn(true); + assertEquals(List.of("give"), complete(staff)); + assertEquals(List.of("give"), complete(staff, "g")); + assertEquals(List.of("give"), complete(staff, (String) null)); + assertEquals(List.of(), complete(staff, "reload")); + assertEquals(List.of(), complete(staff, "other", "")); + assertEquals(List.of("equipment"), complete(staff, "give", "")); + assertTrue(run(staff)); + verify(staff).sendMessage(contains("Usage")); + assertTrue(commands.onCommand(staff, command(), "ac", new String[] {"reload"})); + verify(staff, times(2)).sendMessage(contains("No permission")); + Files.writeString(config, "give-equipment-permission: ' '"); + new ConfigLoader().load(config.toFile()); + assertEquals(AdminPermissions.PERMISSION, Cache.giveEquipmentPermission); + Files.writeString(config, "{}"); + new ConfigLoader().load(config.toFile()); + assertEquals(AdminPermissions.PERMISSION, Cache.giveEquipmentPermission); + } + + @Test + void rejectsUsageTargetsQualityRecipesAndMaterialsBeforeBuilding() throws Exception { + var p = server.addPlayer("Staff"); + p.setOp(true); + var console = mock(CommandSender.class); + when(console.hasPermission(AdminPermissions.PERMISSION)).thenReturn(true); + run(console, "sword", "ingredient.iron"); + verify(console).sendMessage(contains("Specify an online player")); + run(p, "sword", "ingredient.iron", "missing"); + assertTrue(p.nextMessage().contains("Player not found")); + for (String quality : List.of("bad", "NaN", "Infinity", "-Infinity", "-1", "101")) { + run(p, "sword", "ingredient.iron", "Staff", quality); + assertTrue(p.nextMessage().contains("Quality must")); + } + run(p, "sword", "ingredient.iron"); + assertTrue(p.nextMessage().contains("Unknown recipe")); + var r = recipe("recipe: ['metal.4', 'leather.2']"); + RecipeLoader.map.put("sword", r); + ingredient("iron", ""); + for (String id : List.of("iron", "ingredient.missing", "alloy.missing")) { + run(p, "sword", id); + assertTrue(p.nextMessage().contains("incompatible")); + } + run(p, "sword", "ingredient.iron"); + assertTrue(p.nextMessage().contains("No configured ingredient")); + run(p, "sword", "ingredient.iron", "Staff", "50", "extra"); + assertTrue(p.nextMessage().contains("Usage")); + assertEquals(0, p.getInventory().all(Material.IRON_SWORD).size()); + RecipeLoader.map.put("no_main", recipe("main-type: wood\nrecipe: ['metal.1']")); + assertEquals(List.of(), complete(p, "give", "equipment", "no_main", "")); + } + + @Test + void grantsSelfConsoleTargetsAlloysAndOverflowWithExactProvenance() throws Exception { + var p = server.addPlayer("Staff"); + p.setOp(true); + var target = server.addPlayer("Target"); + var iron = ingredient("iron", "tier: 2\nxp: smith(10)\nhits: ['strike.1']"); + TypeLoader.map.put("leather", new IngredientType("leather", yaml("name: Leather"))); + ingredient("z_leather", "type: leather\ntier: 1"); + ingredient("a_leather", "type: leather\ntier: 1"); + ingredient("expensive_leather", "type: leather\ntier: 2"); + var recipe = recipe("recipe: ['metal.4', 'leather.2']"); + RecipeLoader.map.put("sword", recipe); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + var alloy = new Alloy("bronze", "Bronze", new AlloyData(iron, + new StatData(new ArrayList<>()), new HashMap<>(), "smith(10)")); + AlloyManager.addAlloy(alloy); + var wrongAlloy = mock(Alloy.class, RETURNS_DEEP_STUBS); + when(wrongAlloy.getId()).thenReturn("wrong"); + when(wrongAlloy.getData().getType().getId()).thenReturn("wood"); + AlloyManager.addAlloy(wrongAlloy); + assertEquals(List.of("alloy.bronze", "ingredient.iron"), + complete(p, "give", "equipment", "sword", "")); + assertEquals(List.of("sword"), complete(p, "give", "equipment", "S")); + assertEquals(List.of(), complete(p, "give", "equipment", "missing", "")); + assertEquals(List.of("Target"), complete(p, "give", "equipment", "sword", "", "t")); + assertEquals(List.of("100"), complete(p, "give", "equipment", "sword", "", "", "1")); + assertEquals(List.of(), complete(p, "give", "equipment", "sword", "", "", "", "")); + try (var nbt = mockStatic(NBTItem.class); + var mmos = mockConstruction(LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenAnswer(i -> new ItemStack(Material.IRON_SWORD)); + }); + var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var lifecycle = mockStatic(net.tfminecraft.advancedcrafting.lifecycle.CraftLifecycle.class)) { + run(p, "SWORD", "INGREDIENT.IRON"); + var item = p.getInventory().getItem(0); + assertEquals(Material.IRON_SWORD, item.getType()); + var provenance = CraftProvenance.readFrom(item); + assertEquals("sword", provenance.getRecipeId()); + assertEquals("fine", provenance.getQualityId()); + assertEquals(Map.of("ingredient.iron", 4, "ingredient.a_leather", 2), + provenance.getInputs().stream().collect(java.util.stream.Collectors.toMap( + input -> input.getKind() + "." + input.getId(), CraftInput::getAmount))); + assertTrue(target.getWorld().getEntitiesByClass(Item.class).isEmpty()); + var console = mock(CommandSender.class); + when(console.hasPermission(AdminPermissions.PERMISSION)).thenReturn(true); + run(console, "sword", "alloy.bronze", "Target", "0"); + assertEquals(Material.IRON_SWORD, target.getInventory().getItem(0).getType()); + assertTrue(CraftProvenance.readFrom(target.getInventory().getItem(0)).getInputs().stream() + .anyMatch(input -> input.getId().equals("bronze"))); + run(p, "sword", "ingredient.iron", "Target", "100"); + ItemStack[] full = new ItemStack[target.getInventory().getSize()]; + Arrays.fill(full, new ItemStack(Material.STONE, 64)); + target.getInventory().setContents(full); + run(p, "sword", "alloy.bronze", "Target", "50"); + assertEquals(1, target.getWorld().getEntitiesByClass(Item.class).size()); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), anyString()), never()); + lifecycle.verifyNoInteractions(); + } + } + + @Test + void buildingFailuresAreReportedWithoutGrantAndIncompleteBuilderRejects() throws Exception { + var p = server.addPlayer(); + p.setOp(true); + ingredient("iron", ""); + var r = recipe("recipe: ['metal.1']"); + RecipeLoader.map.put("sword", r); + var station = new CraftingStation(p.getLocation()); + station.setRecipe(r); + assertThrows(IllegalStateException.class, () -> station.buildCompletedItem(p, 100)); + p.nextMessage(); + try (var stations = mockConstruction(CraftingStation.class, (mock, ctx) -> { + when(mock.buildCompletedItem(any(), anyDouble())).thenThrow(new IllegalStateException("Bad template")); + })) { + run(p, "sword", "ingredient.iron"); + assertTrue(p.nextMessage().contains("could not be built")); + assertTrue(p.getInventory().isEmpty()); + } + } +}