diff --git a/src/main/java/net/tfminecraft/research/Messages.java b/src/main/java/net/tfminecraft/research/Messages.java index 9ba2d5a..61c2f27 100644 --- a/src/main/java/net/tfminecraft/research/Messages.java +++ b/src/main/java/net/tfminecraft/research/Messages.java @@ -33,6 +33,11 @@ public static String get(String key) { return format(key, Map.of()); } + /** The message for a key that older messages.yml files may lack: the fallback, with the prefix, until it is added. */ + public static String getOrDefault(String key, String fallback) { + return has(key) ? get(key) : StringFormatter.formatHex(prefix + fallback); + } + public static String format(String key, Map placeholders) { return StringFormatter.formatHex(prefix + formatBodyRaw(key, placeholders)); } diff --git a/src/main/java/net/tfminecraft/research/loader/OutputLoader.java b/src/main/java/net/tfminecraft/research/loader/OutputLoader.java index 9515192..a1d01be 100644 --- a/src/main/java/net/tfminecraft/research/loader/OutputLoader.java +++ b/src/main/java/net/tfminecraft/research/loader/OutputLoader.java @@ -3,10 +3,13 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -24,11 +27,42 @@ public final class OutputLoader implements LoaderInterface { private static final Map map = new LinkedHashMap<>(); + private static final Set failedChecks = new HashSet<>(); + // ResearchManager's refused-start log state (see firstRefusal): main thread only, reset on each load. + private static final Set refusalsLogged = new HashSet<>(); public static Map get() { return map; } + /** Ids of loaded outputs that failed a load check. They stay listed, but no project can start them. */ + public static Set failedChecks() { + return Collections.unmodifiableSet(failedChecks); + } + + public static boolean passedChecks(String id) { + return !failedChecks.contains(id); + } + + /** + * True the first time a refused start is reported for this key since the last load, so the console is not + * flooded. Holds ResearchManager's refusal-log state; main thread only; reset on each load. + */ + public static boolean firstRefusal(String key) { + return refusalsLogged.add(key); + } + + /** Test hook: marks an output as failed without loading a file. */ + static void markFailed(String id) { + failedChecks.add(id); + } + + /** Test hook: forgets failed outputs and logged refusals, as a load does. */ + static void resetChecks() { + failedChecks.clear(); + refusalsLogged.clear(); + } + public static OutputDef getById(String id) { return map.get(id); } @@ -40,6 +74,7 @@ public void load(File configFile) { public boolean loadFolder(File folder) { map.clear(); + resetChecks(); boolean ok = true; List files = new ArrayList<>(YamlFolder.listYamlFiles(folder)); files.sort(Comparator.comparing(file -> file.getName().toLowerCase())); @@ -77,7 +112,10 @@ private boolean loadFile(File configFile) { } OutputDef def = new OutputDef(key, section); - ok &= validate(def, configFile); + if (!validate(def, configFile)) { + failedChecks.add(key); + ok = false; + } map.put(key, def); } return ok; @@ -95,6 +133,12 @@ private boolean validate(OutputDef def, File configFile) { + GridLayout.ASPECT_ROW_COUNT + " in " + configFile.getName()); ok = false; } + if (recipeSize <= 0) { + // A recipe with nothing to research would be complete the moment it starts. + Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + + "' lists no aspects and no aspect_pool.pick in " + configFile.getName()); + ok = false; + } ok &= validateAspects(def.getId(), "", def.getAspects(), configFile); ok &= validateAspectPool(def, configFile); @@ -162,6 +206,11 @@ private boolean validateResult(OutputDef def, File configFile) { + "' missing result.item or result.template in " + configFile.getName()); return false; } + if (result.isTemplateInItemKey()) { + Research.plugin.getLogger().warning("[Research] Output '" + def.getId() + "' sets result.item: " + + result.getTemplateRef().trim() + ", which is a template. It is read as result.template;" + + " write it as result.template in " + configFile.getName()); + } if (result.hasTemplate()) { String templateId = ResultRef.templateId(result.getTemplateRef()); if (TemplateLoader.getById(templateId) == null) { diff --git a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java index 782a62a..f0ad055 100644 --- a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java +++ b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java @@ -1,6 +1,7 @@ package net.tfminecraft.research.manager; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -19,6 +20,7 @@ import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; @@ -48,6 +50,7 @@ import net.tfminecraft.research.util.ItemRef; import net.tfminecraft.research.util.OutputPicker; import net.tfminecraft.research.util.PlayerInventoryUtil; +import net.tfminecraft.research.util.ResultRef; import net.tfminecraft.research.util.ResultResolver; import net.tfminecraft.research.util.ResultSpawnEffects; import net.tfminecraft.research.util.RevealHelper; @@ -64,7 +67,18 @@ public final class ResearchManager implements Listener { private final PlayerManager playerManager; private final InventoryManager inventoryManager; + /** Fallback for station.output_unavailable until an older messages.yml gets the key. */ + static final String OUTPUT_UNAVAILABLE = + "#c45749This research is not working right now. Please tell a staff member."; + private final List stations = new ArrayList<>(); + /** + * At most one entry per online player: the lectern and tick of their last main-hand reopen finish. + * An expired entry is removed on that player's next station click; all entries go on quit or unload. + */ + private final Map finishedClicks = new HashMap<>(); + + private record FinishedClick(String lecternKey, int tick) {} public ResearchManager(PlayerManager playerManager) { this.playerManager = playerManager; @@ -91,6 +105,7 @@ public void unloadAll() { } StationStore.saveAll(stations); stations.clear(); + finishedClicks.clear(); } /** Every loaded station, read-only. */ @@ -152,9 +167,15 @@ public void onStationInteract(PlayerInteractEvent event) { return; } + EquipmentSlot hand = event.getHand() != null ? event.getHand() : EquipmentSlot.HAND; + if (isOffHandOfFinishingClick(player, hand, block)) { + // The main hand of this same click just finished a project here; don't start another from the off hand. + event.setCancelled(true); + return; + } + Location stationLoc = block.getLocation(); ResearchStation station = getStationAt(stationLoc); - EquipmentSlot hand = event.getHand() != null ? event.getHand() : EquipmentSlot.HAND; // A lectern with no project still places and opens written books. if (station == null && shouldLeaveLecternToVanilla(block, player, hand)) { return; @@ -174,6 +195,9 @@ public void onStationInteract(PlayerInteractEvent event) { return; } openMainGui(player, station); + if (hand == EquipmentSlot.HAND) { + finishIfAlreadyComplete(player, station); + } return; } @@ -555,13 +579,36 @@ private boolean checkProjectCompletion(Player player, ResearchStation station, O + "' has no resolved result on active project."); return false; } + String savedRef = resultRef; + if (ResultRef.isTemplateRef(savedRef)) { + // Projects started from result.item: t. saved the template itself; roll it now. + resultRef = ResultResolver.resolveToItemRef(savedRef); + if (resultRef == null) { + Research.plugin.getLogger().warning("[Research] Could not resolve saved result template '" + + savedRef + "' for output '" + outputDef.getId() + "' at " + + ResearchStation.locationKey(station.getLocation())); + return false; + } + } ItemStack reward = ItemRef.build(resultRef); if (reward == null) { Research.plugin.getLogger().warning("[Research] Could not build result item for output '" - + outputDef.getId() + "': " + resultRef); + + outputDef.getId() + "': " + resultRef + + (resultRef.equals(savedRef) ? "" : " (rolled from saved template '" + savedRef + "', kept)")); return false; } + if (!resultRef.equals(savedRef)) { + // Only a roll that builds replaces the saved template, so a bad roll cannot pin the station. + Research.plugin.getLogger().info("[Research] Resolved saved result template '" + savedRef + "' to '" + + resultRef + "' for output '" + outputDef.getId() + "'."); + active.setResolvedResultRef(resultRef); + if (!StationStore.saveStation(station)) { + Research.plugin.getLogger().warning("[Research] Could not save the rolled result '" + resultRef + + "' for station " + ResearchStation.locationKey(station.getLocation()) + + "; the reward is still given."); + } + } Location stationLoc = blockLocation(station.getLocation()); if (!ResultSpawnEffects.spawnAtStation(stationLoc, reward)) { @@ -646,15 +693,19 @@ private ResearchStation startProject(Player player, Location location, Equipment String outputId = OutputPicker.roll(input); OutputDef output = outputId != null ? OutputLoader.getById(outputId) : null; if (output == null || !output.isEnabled()) { - player.sendMessage(Messages.get("reload.failed")); + refuseStart(player, input, outputId, outputId == null ? "has no outputs" + : "rolled output '" + outputId + "', which is " + (output == null ? "not loaded" : "disabled")); + return null; + } + if (!OutputLoader.passedChecks(output.getId())) { + refuseStart(player, input, outputId, "rolled output '" + output.getId() + + "', which failed its load checks (see the errors from the last load); fix it and run /research reload"); return null; } String resolvedResultRef = ResultResolver.resolve(output.getResult()); if (resolvedResultRef == null) { - Research.plugin.getLogger().severe("[Research] Could not resolve result for output '" + output.getId() - + "' at station start."); - player.sendMessage(Messages.get("reload.failed")); + refuseStart(player, input, outputId, "could not resolve the result of output '" + output.getId() + "'"); return null; } @@ -674,6 +725,22 @@ private ResearchStation startProject(Player player, Location location, Equipment return station; } + /** + * A configuration problem stops the start before the research paper is taken. The console hears about + * each input and output pair once per load, not on every right-click. + */ + private void refuseStart(Player player, InputDef input, String outputId, String problem) { + if (OutputLoader.firstRefusal(input.getId() + " -> " + outputId)) { + Research.plugin.getLogger().warning("[Research] " + player.getName() + " could not start research '" + + input.getId() + "': it " + problem + ". Logged once until the next load."); + } + sendUnavailable(player); + } + + private static void sendUnavailable(Player player) { + player.sendMessage(Messages.getOrDefault("station.output_unavailable", OUTPUT_UNAVAILABLE)); + } + private boolean tryConsumeStartItemFromHand(Player player, EquipmentSlot hand, InputDef inputDef) { return PlayerInventoryUtil.consumeFromHand( player, @@ -709,6 +776,51 @@ private void openMainGui(Player player, ResearchStation station) { inventoryManager.openMain(player, station); } + /** + * A project whose recipe is already complete but whose reward could not be given (such as a saved + * {@code t.*} template result) finishes when its owner opens it again, without another experiment. + */ + private void finishIfAlreadyComplete(Player player, ResearchStation station) { + ActiveProject project = station.getProject(); + OutputDef outputDef = OutputLoader.getById(project.getResolvedOutputId()); + InventoryView view = player.getOpenInventory(); + // An output that failed its checks (an empty recipe among them) never finishes for free here. + if (outputDef == null || project.isCompleted() || !OutputLoader.passedChecks(outputDef.getId()) + || outputDef.getRecipeAspects(project).isEmpty() || !isRecipeComplete(player, outputDef, project) + || !(view.getTopInventory().getHolder() instanceof StationMenuHolder holder) + || !holder.isCurrent(player) || !isMenuFor(view, station)) { + return; + } + if (checkProjectCompletion(player, station, outputDef, view.getTopInventory())) { + finishedClicks.put(player.getUniqueId(), + new FinishedClick(ResearchStation.locationKey(station.getLocation()), Bukkit.getCurrentTick())); + } else { + sendUnavailable(player); + } + } + + /** + * A real client sends a second, off-hand interact in the same tick when the main hand did nothing on its side. + * After a main-hand reopen finished a project, that off-hand half must not start a new project on the freed lectern. + */ + private boolean isOffHandOfFinishingClick(Player player, EquipmentSlot hand, Block block) { + FinishedClick click = finishedClicks.get(player.getUniqueId()); + if (click == null) { + return false; + } + // The two hand packets of one click can straddle a tick boundary, so the window is this tick and the next. + if (Bukkit.getCurrentTick() - click.tick() > 1) { + finishedClicks.remove(player.getUniqueId()); + return false; + } + return hand == EquipmentSlot.OFF_HAND && click.lecternKey().equals(ResearchStation.locationKey(block.getLocation())); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + finishedClicks.remove(event.getPlayer().getUniqueId()); + } + /** * Resolves the station a research menu was opened for. Each menu carries its lectern location, * so a player with several stations only ever acts on the one whose menu is open. diff --git a/src/main/java/net/tfminecraft/research/model/ActiveProject.java b/src/main/java/net/tfminecraft/research/model/ActiveProject.java index baacd5e..90f75c9 100644 --- a/src/main/java/net/tfminecraft/research/model/ActiveProject.java +++ b/src/main/java/net/tfminecraft/research/model/ActiveProject.java @@ -17,7 +17,7 @@ public final class ActiveProject { private final String inputId; private final String resolvedOutputId; - private final String resolvedResultRef; + private String resolvedResultRef; private boolean productRevealed; private boolean completed; private final Map aspectStates = new HashMap<>(); @@ -55,6 +55,11 @@ public String getResolvedResultRef() { return resolvedResultRef; } + /** Replaces a saved result that was still a template ref with the item it rolled. */ + public void setResolvedResultRef(String resolvedResultRef) { + this.resolvedResultRef = resolvedResultRef; + } + /** * @deprecated Use {@link #getResolvedOutputId()} for the output-centric model. */ diff --git a/src/main/java/net/tfminecraft/research/model/OutputDef.java b/src/main/java/net/tfminecraft/research/model/OutputDef.java index cb6da6a..368ee6b 100644 --- a/src/main/java/net/tfminecraft/research/model/OutputDef.java +++ b/src/main/java/net/tfminecraft/research/model/OutputDef.java @@ -11,6 +11,8 @@ import org.bukkit.configuration.ConfigurationSection; +import net.tfminecraft.research.util.ResultRef; + public final class OutputDef { private final String id; @@ -156,15 +158,20 @@ private static Map readAspects(ConfigurationSection s public static final class ResultSpec { private final String itemRef; private final String templateRef; + private final boolean templateInItemKey; public ResultSpec(ConfigurationSection config) { - if (config != null) { - this.itemRef = config.getString("item", ""); - this.templateRef = config.getString("template", ""); - } else { - this.itemRef = ""; - this.templateRef = ""; - } + String item = config != null ? config.getString("item", "") : ""; + String template = config != null ? config.getString("template", "") : ""; + // result.item: t. is a template written under the wrong key; read it as result.template. + this.templateInItemKey = ResultRef.isTemplateRef(item) && (template == null || template.isBlank()); + this.itemRef = templateInItemKey ? "" : item; + this.templateRef = templateInItemKey ? item : template; + } + + /** True when the template was written as {@code result.item: t.} instead of {@code result.template}. */ + public boolean isTemplateInItemKey() { + return templateInItemKey; } public String getItemRef() { diff --git a/src/main/java/net/tfminecraft/research/util/ResultResolver.java b/src/main/java/net/tfminecraft/research/util/ResultResolver.java index 7796568..ad99d07 100644 --- a/src/main/java/net/tfminecraft/research/util/ResultResolver.java +++ b/src/main/java/net/tfminecraft/research/util/ResultResolver.java @@ -25,7 +25,11 @@ public static String resolve(OutputDef.ResultSpec result) { return null; } - private static String resolveToItemRef(String ref) { + /** + * Rolls a saved or configured result down to an item ref: a {@code t.*} template is rolled (nested + * templates too), an item ref is returned as it is, and anything else gives null. + */ + public static String resolveToItemRef(String ref) { if (ref == null || ref.isBlank()) { return null; } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index d538677..f33dbb4 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -15,6 +15,7 @@ station: hold_start_item: "#c45749Hold a research paper in your hand to use this station." invalid_start_item: "#c45749That item cannot start a research project." missing_start_item: "#c45749You need that research paper in your hand to start." + output_unavailable: "#c45749This research is not working right now. Please tell a staff member." scrapped: "#c45749Research project scrapped." broken: "#c45749Research at this lectern was destroyed." scrap_warning: "#c45749WARNING! #ff5555The project will not be refunded!" diff --git a/src/test/java/net/tfminecraft/research/ResearchTestState.java b/src/test/java/net/tfminecraft/research/ResearchTestState.java index 76f31e9..e4a5e3f 100644 --- a/src/test/java/net/tfminecraft/research/ResearchTestState.java +++ b/src/test/java/net/tfminecraft/research/ResearchTestState.java @@ -6,7 +6,9 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; /** Restores process-wide configuration and registries after an isolated test. */ public final class ResearchTestState implements AutoCloseable { @@ -21,6 +23,7 @@ public ResearchTestState() throws IllegalAccessException { Object value = field.get(null); if (Modifier.isFinal(field.getModifiers())) { if (value instanceof Map map) values.put(field, new LinkedHashMap<>(map)); + else if (value instanceof Set set) values.put(field, new LinkedHashSet<>(set)); } else { values.put(field, value); } @@ -32,7 +35,11 @@ public ResearchTestState() throws IllegalAccessException { @SuppressWarnings("unchecked") public void close() throws IllegalAccessException { for (var saved : values.entrySet()) { - if (Modifier.isFinal(saved.getKey().getModifiers())) { + if (saved.getValue() instanceof Set set && Modifier.isFinal(saved.getKey().getModifiers())) { + Set live = (Set) saved.getKey().get(null); + live.clear(); + live.addAll(set); + } else if (Modifier.isFinal(saved.getKey().getModifiers())) { Map map = (Map) saved.getKey().get(null); map.clear(); map.putAll((Map) saved.getValue()); diff --git a/src/test/java/net/tfminecraft/research/ResearchTextAndModelTest.java b/src/test/java/net/tfminecraft/research/ResearchTextAndModelTest.java index 879a023..e912890 100644 --- a/src/test/java/net/tfminecraft/research/ResearchTextAndModelTest.java +++ b/src/test/java/net/tfminecraft/research/ResearchTextAndModelTest.java @@ -39,6 +39,11 @@ class ResearchTextAndModelTest { for (var style : GuiText.AspectNameStyle.values()) assertEquals("name",ChatColor.stripColor(GuiText.aspectName(style,"§aname"))); assertEquals("",GuiText.aspectName(GuiText.AspectNameStyle.PLAIN,null)); } + @Test void messagesMissingFromAnOlderMessagesFileUseTheirFallbackWithThePrefix() { + Messages.clear(); Messages.setPrefix("[Research] "); Messages.put("station.output_unavailable", "Configured"); + assertEquals("[Research] Configured", Messages.getOrDefault("station.output_unavailable", "Fallback")); + assertEquals("[Research] Fallback", Messages.getOrDefault("station.absent", "Fallback")); + } @Test void completionEventPublishesStablePluginApi() { var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); var event = new net.tfminecraft.research.event.ResearchCompleteEvent(player,"input","output","v.paper"); diff --git a/src/test/java/net/tfminecraft/research/loader/OutputChecks.java b/src/test/java/net/tfminecraft/research/loader/OutputChecks.java new file mode 100644 index 0000000..b6f2f3f --- /dev/null +++ b/src/test/java/net/tfminecraft/research/loader/OutputChecks.java @@ -0,0 +1,15 @@ +package net.tfminecraft.research.loader; + +/** Lets tests in other packages set which outputs failed their load checks. */ +public final class OutputChecks { + + private OutputChecks() {} + + public static void markFailed(String id) { + OutputLoader.markFailed(id); + } + + public static void reset() { + OutputLoader.resetChecks(); + } +} diff --git a/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java b/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java index ee4ee0a..01cfcbe 100644 --- a/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java +++ b/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java @@ -222,6 +222,74 @@ void outputValidationChecksDisplaysAspectsAndExclusiveResultKinds() throws Excep assertNotNull(OutputLoader.getById("single")); } + @Test + void templateWrittenAsResultItemLoadsAsATemplateWithAWarning() throws Exception { + AspectLoader.get().put("fire", new AspectDef("fire", config("{}"))); + TemplateLoader.get().put("loot", new ResultTemplateDef("loot", List.of(new ResultTemplateDef.WeightedOutput("v.paper", 1)))); + File file = yaml("alias-outputs/a.yml", """ + alias: + mystery_display: {item: v.paper} + product_display: {item: v.paper} + aspects: {fire: {required_points: 1}} + result: {item: " t.loot "} + alias-and-template: + mystery_display: {item: v.paper} + product_display: {item: v.paper} + aspects: {fire: {required_points: 1}} + result: {item: t.loot, template: t.loot} + """); + + assertFalse(new OutputLoader().loadFolder(file.getParentFile())); + + OutputDef.ResultSpec alias = OutputLoader.getById("alias").getResult(); + assertTrue(alias.isTemplateInItemKey()); + assertTrue(alias.hasTemplate()); + assertFalse(alias.hasItem()); + assertTrue(OutputLoader.passedChecks("alias")); + verify(logger).warning(contains("Output 'alias' sets result.item: t.loot, which is a template." + + " It is read as result.template; write it as result.template in a.yml")); + OutputDef.ResultSpec both = OutputLoader.getById("alias-and-template").getResult(); + assertFalse(both.isTemplateInItemKey()); + assertTrue(both.hasItem() && both.hasTemplate()); + assertFalse(OutputLoader.passedChecks("alias-and-template")); + assertEquals(java.util.Set.of("alias-and-template"), OutputLoader.failedChecks()); + assertThrows(UnsupportedOperationException.class, () -> OutputLoader.failedChecks().add("x")); + } + + @Test + void outputsWithNothingToResearchFailTheirChecks() throws Exception { + AspectLoader.get().put("fire", new AspectDef("fire", config("{}"))); + File file = yaml("empty-recipe/a.yml", """ + nothing: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}} + fixed: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspects: {fire: {required_points: 1}}} + pooled: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspect_pool: {pick: 1, aspects: {fire: {required_points: 1}}}} + """); + + assertFalse(new OutputLoader().loadFolder(file.getParentFile())); + + assertEquals(java.util.Set.of("nothing"), OutputLoader.failedChecks()); + verify(logger).severe(contains("Output 'nothing' lists no aspects and no aspect_pool.pick in a.yml")); + assertTrue(OutputLoader.firstRefusal("input -> nothing")); + assertFalse(OutputLoader.firstRefusal("input -> nothing")); + new OutputLoader().loadFolder(file.getParentFile()); + assertTrue(OutputLoader.firstRefusal("input -> nothing"), "a load forgets logged refusals"); + } + + @Test + void outputsThatFailTheirChecksAreListedUntilTheNextLoad() throws Exception { + AspectLoader.get().put("fire", new AspectDef("fire", config("{}"))); + File bad = yaml("checked-bad/a.yml", "broken: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, aspects: {fire: {required_points: 1}}, result: {item: t.missing}}\n"); + assertFalse(new OutputLoader().loadFolder(bad.getParentFile())); + assertNotNull(OutputLoader.getById("broken")); + assertFalse(OutputLoader.passedChecks("broken")); + verify(logger).severe(contains("Output 'broken' references unknown template 'missing'")); + + File good = yaml("checked-good/a.yml", "broken: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, aspects: {fire: {required_points: 1}}, result: {item: v.paper}}\n"); + assertTrue(new OutputLoader().loadFolder(good.getParentFile())); + assertTrue(OutputLoader.passedChecks("broken")); + assertTrue(OutputLoader.failedChecks().isEmpty()); + } + @Test void outputAspectPoolsLoadAndValidatePickSizeOverlapAndRows() throws Exception { for (String id : List.of("fire", "water", "earth", "air")) AspectLoader.get().put(id, new AspectDef(id, config("{}"))); diff --git a/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java b/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java index 46db82a..4010fe0 100644 --- a/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java +++ b/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java @@ -37,6 +37,7 @@ import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; @@ -59,7 +60,9 @@ import net.tfminecraft.research.database.StationStore; import net.tfminecraft.research.event.ResearchCompleteEvent; import net.tfminecraft.research.loader.AspectLoader; +import net.tfminecraft.research.loader.OutputChecks; import net.tfminecraft.research.loader.OutputLoader; +import net.tfminecraft.research.loader.TemplateLoader; import net.tfminecraft.research.model.ActiveProject; import net.tfminecraft.research.model.AspectDef; import net.tfminecraft.research.model.AspectState; @@ -67,6 +70,7 @@ import net.tfminecraft.research.model.InputDef; import net.tfminecraft.research.model.OutputDef; import net.tfminecraft.research.model.ResearchStation; +import net.tfminecraft.research.model.ResultTemplateDef; import net.tfminecraft.research.registry.AspectItemRegistry; import net.tfminecraft.research.util.GridLayout; import net.tfminecraft.research.util.InputMatcher; @@ -81,6 +85,7 @@ class ResearchManagerTest { private final List mocks = new ArrayList<>(); private final Map originalCache = new LinkedHashMap<>(); private Map originalOutputs; + private Map originalTemplates; private Map originalAspects; private Research originalPlugin; private ResearchManager originalManager; @@ -124,6 +129,9 @@ void setUp() throws Exception { originalCache.put(field, field.get(null)); } originalOutputs = new LinkedHashMap<>(OutputLoader.get()); + originalTemplates = new LinkedHashMap<>(TemplateLoader.get()); + TemplateLoader.get().clear(); + OutputChecks.reset(); originalAspects = new LinkedHashMap<>(AspectLoader.get()); OutputLoader.get().clear(); AspectLoader.get().clear(); @@ -219,6 +227,7 @@ void setUp() throws Exception { sounds = scoped(mockStatic(SoundKeys.class)); messages = scoped(mockStatic(Messages.class)); messages.when(() -> Messages.get(anyString())).thenAnswer(call -> call.getArgument(0)); + messages.when(() -> Messages.getOrDefault(anyString(), anyString())).thenAnswer(call -> call.getArgument(0)); messages.when(() -> Messages.format(anyString(), anyMap())) .thenAnswer(call -> call.getArgument(0) + " " + call.getArgument(1)); scoped(mockStatic(MmoAttributes.class)); @@ -241,6 +250,9 @@ void restoreState() throws Exception { } OutputLoader.get().clear(); OutputLoader.get().putAll(originalOutputs); + OutputChecks.reset(); + TemplateLoader.get().clear(); + TemplateLoader.get().putAll(originalTemplates); AspectLoader.get().clear(); AspectLoader.get().putAll(originalAspects); Research.plugin = originalPlugin; @@ -612,7 +624,7 @@ void onlyDrawnPoolAspectsCountTowardTheRecipe() { } @ParameterizedTest - @ValueSource(strings = {"unknown-output", "disabled", "unresolved", "missing-item"}) + @ValueSource(strings = {"unknown-output", "disabled", "failed-checks", "unresolved", "missing-item"}) void startFailuresDoNotCreateOrConsumeAProject(String failure) { noStations(); ItemStack held = stack(Material.PAPER, 1); @@ -624,12 +636,21 @@ void startFailuresDoNotCreateOrConsumeAProject(String failure) { if (failure.equals("disabled")) config.set("enabled", false); if (failure.equals("unresolved")) config.set("result", null); if (!failure.equals("unknown-output")) OutputLoader.get().put("output", new OutputDef("output", config)); + if (failure.equals("failed-checks")) OutputChecks.markFailed("output"); manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); assertNull(manager.getStationAt(location)); assertEquals(1, held.getAmount()); - verify(player).sendMessage(failure.equals("missing-item") ? "station.missing_start_item" : "reload.failed"); + verify(player).sendMessage(failure.equals("missing-item") ? "station.missing_start_item" : "station.output_unavailable"); + if (!failure.equals("missing-item")) { + verify(logger).warning(contains("Researcher could not start research 'input': it " + switch (failure) { + case "unknown-output" -> "rolled output 'output', which is not loaded."; + case "disabled" -> "rolled output 'output', which is disabled."; + case "failed-checks" -> "rolled output 'output', which failed its load checks"; + default -> "could not resolve the result of output 'output'."; + })); + } store.verify(() -> StationStore.saveStation(any()), never()); startEffects.verifyNoInteractions(); } @@ -836,7 +857,8 @@ void inputWithoutAnyOutputsFailsBeforeConsumingTheStartItem() { manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); assertNull(manager.getStationAt(location)); - verify(player).sendMessage("reload.failed"); + verify(player).sendMessage("station.output_unavailable"); + verify(logger).warning(contains("could not start research 'input': it has no outputs.")); assertEquals(1, held.getAmount()); } @@ -1227,6 +1249,305 @@ void completionListenerCannotCloseAReplacementMenu(boolean switchesCharacter) { completeEffects.verify(() -> StationCompleteEffects.play(player, location), times(switchesCharacter ? 0 : 1)); } + @Test + void savedTemplateResultIsRolledSavedAndGrantedAtCompletion() { + prepareExperiment("fire", null, Map.of("fire", 2)); + project = new ActiveProject("input", "output", "t.greater_runestone"); + station.setProject(project); + TemplateLoader.get().put("greater_runestone", new ResultTemplateDef("greater_runestone", + List.of(new ResultTemplateDef.WeightedOutput("m.loot.rune", 1)))); + items.when(() -> ItemRef.hasKnownPrefix("m.loot.rune")).thenReturn(true); + ItemStack reward = stack(Material.PAPER, 1); + items.when(() -> ItemRef.build("m.loot.rune")).thenReturn(reward); + rewards.when(() -> ResultSpawnEffects.spawnAtStation(location, reward)).thenReturn(true); + List savedRefs = new ArrayList<>(); + store.when(() -> StationStore.saveStation(station)).thenAnswer(call -> { + savedRefs.add(station.getProject().getResolvedResultRef()); return true; + }); + + confirm(); + + assertTrue(project.isCompleted()); + assertEquals("m.loot.rune", project.getResolvedResultRef()); + assertEquals(List.of("m.loot.rune"), savedRefs); + assertNull(manager.getStationAt(location)); + verify(logger).info(contains("Resolved saved result template 't.greater_runestone' to 'm.loot.rune'")); + ArgumentCaptor event = ArgumentCaptor.forClass(ResearchCompleteEvent.class); + verify(pluginManager).callEvent(event.capture()); + assertEquals("m.loot.rune", event.getValue().getResultItemRef()); + store.verify(() -> StationStore.deleteStation(location)); + } + + @Test + void savedTemplateThatNoLongerExistsKeepsTheProjectAndExplainsWhy() { + prepareExperiment("fire", null, Map.of("fire", 2)); + project = new ActiveProject("input", "output", "t.gone"); + station.setProject(project); + + confirm(); + + assertFalse(project.isCompleted()); + assertEquals("t.gone", project.getResolvedResultRef()); + assertSame(station, manager.getStationAt(location)); + verify(logger).warning(contains("Could not resolve saved result template 't.gone' for output 'output' at " + + ResearchStation.locationKey(location))); + items.verify(() -> ItemRef.build(anyString()), never()); + verifyNoInteractions(pluginManager); + } + + @Test + void reopeningAProjectWhoseRecipeIsAlreadyCompleteFinishesIt() { + output(Map.of("fire", 2)); + project = new ActiveProject("input", "output", "t.greater_runestone"); + project.setAspectState("fire", AspectState.CONFIRMED); + project.addAspectPoints("fire", 2, 2); + station.setProject(project); + TemplateLoader.get().put("greater_runestone", new ResultTemplateDef("greater_runestone", + List.of(new ResultTemplateDef.WeightedOutput("m.loot.rune", 1)))); + items.when(() -> ItemRef.hasKnownPrefix("m.loot.rune")).thenReturn(true); + ItemStack reward = stack(Material.PAPER, 1); + items.when(() -> ItemRef.build("m.loot.rune")).thenReturn(reward); + rewards.when(() -> ResultSpawnEffects.spawnAtStation(location, reward)).thenReturn(true); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + verify(menus).openMain(player, station); + assertTrue(project.isCompleted()); + assertNull(manager.getStationAt(location)); + verify(player).sendMessage("station.completed"); + store.verify(() -> StationStore.deleteStation(location)); + completeEffects.verify(() -> StationCompleteEffects.play(player, location)); + } + + @ParameterizedTest + @ValueSource(strings = {"incomplete", "no-output", "already-completed", "failed-checks", "empty-recipe", + "foreign-menu", "other-station", "stale-menu", "off-hand"}) + void reopeningOnlyFinishesACompleteProjectThroughItsOwnMenu(String situation) { + output(Map.of("fire", 2)); + if (!situation.equals("incomplete")) { + project.setAspectState("fire", AspectState.CONFIRMED); + project.addAspectPoints("fire", 2, 2); + } + if (situation.equals("no-output")) OutputLoader.get().clear(); + if (situation.equals("failed-checks")) OutputChecks.markFailed("output"); + if (situation.equals("empty-recipe")) output(Map.of()); + if (situation.equals("already-completed")) project.setCompleted(true); + if (situation.equals("foreign-menu")) when(top.getHolder()).thenReturn(null); + if (situation.equals("other-station")) { + when(top.getHolder()).thenReturn(new StationMenuHolder(location.clone().add(5, 0, 0), session)); + } + if (situation.equals("stale-menu")) { + when(top.getHolder()).thenReturn(new StationMenuHolder(location, new CharacterSession(session.ref(), 9))); + } + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, + situation.equals("off-hand") ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND)); + + verify(menus).openMain(player, station); + assertSame(station, manager.getStationAt(location)); + if (!situation.equals("already-completed")) assertFalse(project.isCompleted()); + items.verify(() -> ItemRef.build(anyString()), never()); + store.verify(() -> StationStore.deleteStation(any()), never()); + verify(player, never()).sendMessage("station.output_unavailable"); + } + + @ParameterizedTest + @ValueSource(strings = {"unbuildable", "spawn-failed"}) + void reopeningACompleteProjectWhoseRewardFailsTellsThePlayer(String failure) { + output(Map.of("fire", 2)); + project.setAspectState("fire", AspectState.CONFIRMED); + project.addAspectPoints("fire", 2, 2); + if (failure.equals("spawn-failed")) { + ItemStack reward = stack(Material.DIAMOND, 1); + items.when(() -> ItemRef.build("vanilla.DIAMOND")).thenReturn(reward); + } + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + assertFalse(project.isCompleted()); + assertSame(station, manager.getStationAt(location)); + verify(player).sendMessage("station.output_unavailable"); + verify(logger).warning(contains(failure.equals("unbuildable") ? "Could not build" : "Could not spawn")); + store.verify(() -> StationStore.deleteStation(any()), never()); + } + + @Test + void anotherPlayerCannotFinishACompleteProjectByOpeningIt() { + output(Map.of("fire", 2)); + project.setAspectState("fire", AspectState.CONFIRMED); + project.addAspectPoints("fire", 2, 2); + station.setOwnerUuid(UUID.randomUUID()); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + verify(player).sendMessage("station.in_use"); + verifyNoInteractions(menus); + assertFalse(project.isCompleted()); + items.verify(() -> ItemRef.build(anyString()), never()); + store.verify(() -> StationStore.deleteStation(any()), never()); + } + + @Test + void aRolledTemplateThatCannotBeBuiltKeepsTheTemplateSaved() { + prepareExperiment("fire", null, Map.of("fire", 2)); + project = new ActiveProject("input", "output", "t.greater_runestone"); + station.setProject(project); + TemplateLoader.get().put("greater_runestone", new ResultTemplateDef("greater_runestone", + List.of(new ResultTemplateDef.WeightedOutput("m.loot.rune", 1)))); + items.when(() -> ItemRef.hasKnownPrefix("m.loot.rune")).thenReturn(true); + List savedRefs = new ArrayList<>(); + store.when(() -> StationStore.saveStation(station)).thenAnswer(call -> { + savedRefs.add(station.getProject().getResolvedResultRef()); return true; + }); + + confirm(); + + assertFalse(project.isCompleted()); + assertEquals("t.greater_runestone", project.getResolvedResultRef()); + assertEquals(List.of("t.greater_runestone"), savedRefs); + verify(logger).warning(contains("Could not build result item for output 'output': m.loot.rune" + + " (rolled from saved template 't.greater_runestone', kept)")); + verify(logger, never()).info(contains("Resolved saved result template")); + verifyNoInteractions(pluginManager); + } + + @Test + void aRolledResultThatCannotBeSavedIsStillGrantedAndLogged() { + prepareExperiment("fire", null, Map.of("fire", 2)); + project = new ActiveProject("input", "output", "t.greater_runestone"); + station.setProject(project); + TemplateLoader.get().put("greater_runestone", new ResultTemplateDef("greater_runestone", + List.of(new ResultTemplateDef.WeightedOutput("m.loot.rune", 1)))); + items.when(() -> ItemRef.hasKnownPrefix("m.loot.rune")).thenReturn(true); + ItemStack reward = stack(Material.PAPER, 1); + items.when(() -> ItemRef.build("m.loot.rune")).thenReturn(reward); + rewards.when(() -> ResultSpawnEffects.spawnAtStation(location, reward)).thenReturn(true); + store.when(() -> StationStore.saveStation(station)).thenReturn(false); + + confirm(); + + verify(logger).warning(contains("Could not save the rolled result 'm.loot.rune' for station " + + ResearchStation.locationKey(location) + "; the reward is still given.")); + assertTrue(project.isCompleted()); + assertNull(manager.getStationAt(location)); + store.verify(() -> StationStore.deleteStation(location)); + } + + @Test + void aRefusedStartIsLoggedOncePerInputAndOutputUntilTheNextLoad() { + noStations(); + ItemStack held = stack(Material.PAPER, 1); + when(bottom.getItemInMainHand()).thenReturn(held); + inputs.when(() -> InputMatcher.findByStartItem(held)).thenReturn(input("vanilla.PAPER", 1, "output")); + output(Map.of("fire", 2)); + OutputChecks.markFailed("output"); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + verify(player, times(2)).sendMessage("station.output_unavailable"); + verify(logger, times(1)).warning(contains("failed its load checks")); + + OutputChecks.reset(); + OutputChecks.markFailed("output"); + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + verify(logger, times(2)).warning(contains("failed its load checks")); + assertEquals(1, held.getAmount()); + } + + /** Seeds a complete project whose reward builds, so a main-hand reopen finishes it. */ + private ItemStack finishableProjectWithStartItemInOffHand() { + output(Map.of("fire", 2)); + project.setAspectState("fire", AspectState.CONFIRMED); + project.addAspectPoints("fire", 2, 2); + ItemStack reward = stack(Material.DIAMOND, 1); + items.when(() -> ItemRef.build("vanilla.DIAMOND")).thenReturn(reward); + rewards.when(() -> ResultSpawnEffects.spawnAtStation(location, reward)).thenReturn(true); + ItemStack paper = stack(Material.PAPER, 2); + when(bottom.getItemInOffHand()).thenReturn(paper); + inputs.when(() -> InputMatcher.findByStartItem(paper)).thenReturn(input("vanilla.PAPER", 1, "output")); + items.when(() -> ItemRef.matches(paper, "vanilla.PAPER")).thenReturn(true); + return paper; + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + void offHandHalfOfTheFinishingClickDoesNotStartANewProject(int ticksLater) { + ItemStack paper = finishableProjectWithStartItemInOffHand(); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + bukkit.when(Bukkit::getCurrentTick).thenReturn(ticksLater); + PlayerInteractEvent offHand = interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.OFF_HAND); + manager.onStationInteract(offHand); + + assertTrue(project.isCompleted()); + assertNull(manager.getStationAt(location)); + assertTrue(offHand.isCancelled()); + assertEquals(2, paper.getAmount()); + inputs.verify(() -> InputMatcher.findByStartItem(any()), never()); + verify(player, never()).sendMessage("station.started"); + store.verify(() -> StationStore.saveStation(any()), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"later-tick", "other-lectern", "after-quit", "after-unload", "main-hand-same-tick"}) + void startsStillWorkOutsideTheFinishingClick(String situation) { + ItemStack paper = finishableProjectWithStartItemInOffHand(); + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + assertNull(manager.getStationAt(location)); + Block target = lectern; + Location targetLocation = location; + EquipmentSlot hand = EquipmentSlot.OFF_HAND; + switch (situation) { + case "later-tick" -> bukkit.when(Bukkit::getCurrentTick).thenReturn(2); + case "main-hand-same-tick" -> { + when(bottom.getItemInMainHand()).thenReturn(paper); + hand = EquipmentSlot.HAND; + } + case "other-lectern" -> { + targetLocation = new Location(world, 30, 64, 20); + target = mock(Block.class); + when(target.getType()).thenReturn(Material.LECTERN); + when(target.getLocation()).thenReturn(targetLocation); + } + case "after-quit" -> manager.onPlayerQuit(new PlayerQuitEvent(player, (String) null)); + default -> { + store.when(StationStore::loadAll).thenReturn(List.of()); + manager.unloadAll(); + manager.start(); + } + } + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, target, hand)); + + ResearchStation started = manager.getStationAt(targetLocation); + assertNotNull(started); + assertEquals("input", started.getProject().getInputId()); + assertEquals(1, paper.getAmount()); + verify(player).sendMessage("station.started"); + } + + @Test + void withoutAFinishTheOffHandHalfOfAClickStillStartsResearch() { + noStations(); + ItemStack paper = stack(Material.PAPER, 2); + ItemStack dirt = stack(Material.DIRT, 1); + when(bottom.getItemInMainHand()).thenReturn(dirt); + when(bottom.getItemInOffHand()).thenReturn(paper); + inputs.when(() -> InputMatcher.findByStartItem(paper)).thenReturn(input("vanilla.PAPER", 1, "output")); + items.when(() -> ItemRef.matches(paper, "vanilla.PAPER")).thenReturn(true); + output(Map.of("fire", 2)); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + verify(player).sendMessage("station.invalid_start_item"); + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.OFF_HAND)); + + assertNotNull(manager.getStationAt(location)); + assertEquals(1, paper.getAmount()); + verify(player).sendMessage("station.started"); + } + private ItemStack prepareExperiment(String primary, String secondary, Map recipe) { output(recipe); ItemStack item = stack(Material.COAL, 1); diff --git a/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java b/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java index ed3f231..60b97a0 100644 --- a/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java @@ -361,6 +361,21 @@ void directResultsAreTrimmedAndMissingResultsReturnNull() { assertEquals("vanilla.PAPER", ResultResolver.resolve(new OutputDef.ResultSpec(config))); } + @Test + void templateWrittenAsResultItemResolvesLikeAResultTemplate() { + TemplateLoader.get().put("loot", new ResultTemplateDef("loot", List.of( + new ResultTemplateDef.WeightedOutput("vanilla.EMERALD", 1)))); + YamlConfiguration config = new YamlConfiguration(); + config.set("item", "t.loot"); + OutputDef.ResultSpec spec = new OutputDef.ResultSpec(config); + assertTrue(spec.isTemplateInItemKey()); + assertEquals("vanilla.EMERALD", ResultResolver.resolve(spec)); + assertEquals("vanilla.EMERALD", ResultResolver.resolveToItemRef("t.loot")); + assertEquals("vanilla.PAPER", ResultResolver.resolveToItemRef("vanilla.PAPER")); + assertNull(ResultResolver.resolveToItemRef("t.absent")); + assertNull(ResultResolver.resolveToItemRef("not-an-item")); + } + @Test void controlOnlyDirectResultsAreRejectedAfterTrimming() throws Exception { YamlConfiguration config = new YamlConfiguration();