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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/net/tfminecraft/research/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> placeholders) {
return StringFormatter.formatHex(prefix + formatBodyRaw(key, placeholders));
}
Expand Down
51 changes: 50 additions & 1 deletion src/main/java/net/tfminecraft/research/loader/OutputLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,11 +27,42 @@
public final class OutputLoader implements LoaderInterface {

private static final Map<String, OutputDef> map = new LinkedHashMap<>();
private static final Set<String> failedChecks = new HashSet<>();
// ResearchManager's refused-start log state (see firstRefusal): main thread only, reset on each load.
private static final Set<String> refusalsLogged = new HashSet<>();

public static Map<String, OutputDef> get() {
return map;
}

/** Ids of loaded outputs that failed a load check. They stay listed, but no project can start them. */
public static Set<String> 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);
}
Expand All @@ -40,6 +74,7 @@ public void load(File configFile) {

public boolean loadFolder(File folder) {
map.clear();
resetChecks();
boolean ok = true;
List<File> files = new ArrayList<>(YamlFolder.listYamlFiles(folder));
files.sort(Comparator.comparing(file -> file.getName().toLowerCase()));
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
124 changes: 118 additions & 6 deletions src/main/java/net/tfminecraft/research/manager/ResearchManager.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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<ResearchStation> 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<UUID, FinishedClick> finishedClicks = new HashMap<>();

private record FinishedClick(String lecternKey, int tick) {}

public ResearchManager(PlayerManager playerManager) {
this.playerManager = playerManager;
Expand All @@ -91,6 +105,7 @@ public void unloadAll() {
}
StationStore.saveAll(stations);
stations.clear();
finishedClicks.clear();
}

/** Every loaded station, read-only. */
Expand Down Expand Up @@ -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;
Expand All @@ -174,6 +195,9 @@ public void onStationInteract(PlayerInteractEvent event) {
return;
}
openMainGui(player, station);
if (hand == EquipmentSlot.HAND) {
finishIfAlreadyComplete(player, station);
}
return;
}

Expand Down Expand Up @@ -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.<id> 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)) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> aspectStates = new HashMap<>();
Expand Down Expand Up @@ -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.
*/
Expand Down
21 changes: 14 additions & 7 deletions src/main/java/net/tfminecraft/research/model/OutputDef.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import org.bukkit.configuration.ConfigurationSection;

import net.tfminecraft.research.util.ResultRef;

public final class OutputDef {

private final String id;
Expand Down Expand Up @@ -156,15 +158,20 @@ private static Map<String, AspectRequirement> 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.<id> 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.<id>} instead of {@code result.template}. */
public boolean isTemplateInItemKey() {
return templateInItemKey;
}

public String getItemRef() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading