diff --git a/pom.xml b/pom.xml
index 65440e6..9479e23 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,7 +67,7 @@
-LOCAL
- 1.25.2
+ 1.26.0
BentoBoxWorld_AOneBlock
bentobox-world
diff --git a/src/main/java/world/bentobox/aoneblock/commands/admin/AdminCommand.java b/src/main/java/world/bentobox/aoneblock/commands/admin/AdminCommand.java
index 31813c9..9d46eed 100644
--- a/src/main/java/world/bentobox/aoneblock/commands/admin/AdminCommand.java
+++ b/src/main/java/world/bentobox/aoneblock/commands/admin/AdminCommand.java
@@ -21,6 +21,8 @@ public void setup() {
new AdminSetChestCommand(this);
// Sanity
new AdminSanityCheck(this);
+ // Phase order editor
+ new AdminPhasesCommand(this);
}
}
diff --git a/src/main/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommand.java b/src/main/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommand.java
new file mode 100644
index 0000000..c510c91
--- /dev/null
+++ b/src/main/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommand.java
@@ -0,0 +1,51 @@
+package world.bentobox.aoneblock.commands.admin;
+
+import java.util.List;
+
+import world.bentobox.aoneblock.AOneBlock;
+import world.bentobox.aoneblock.panels.AdminPhasesPanel;
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Command to open the phase order editor
+ *
+ * @author tastybento
+ */
+public class AdminPhasesCommand extends CompositeCommand {
+
+ private AOneBlock addon;
+
+ public AdminPhasesCommand(CompositeCommand parent) {
+ super(parent, "phases");
+ }
+
+ @Override
+ public void setup() {
+ setDescription("aoneblock.commands.admin.phases.description");
+ // Permission
+ setPermission("admin.phases");
+ setOnlyPlayer(true);
+ addon = getAddon();
+ }
+
+ @Override
+ public boolean canExecute(User user, String label, List args) {
+ if (!args.isEmpty()) {
+ showHelp(this, user);
+ return false;
+ }
+ if (addon.getOneBlockManager().getPhaseIndex().isEmpty()) {
+ // Phases were loaded without an index, so there is nothing to reorder
+ user.sendMessage("aoneblock.commands.admin.phases.no-index");
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean execute(User user, String label, List args) {
+ AdminPhasesPanel.openPanel(addon, user);
+ return true;
+ }
+}
diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java
index 2f56fdd..1f45e90 100644
--- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java
+++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java
@@ -50,6 +50,8 @@ public class OneBlockPhase {
private final Random chestRandom;
private final String blockNumber;
private Integer gotoBlock;
+ private String requiredMinecraftVersion;
+ private PhaseIndexEntry indexEntry;
private int blockTotal = 0;
private int entityTotal = 0;
private List startCommands;
@@ -340,6 +342,37 @@ public void setGotoBlock(Integer gotoBlock) {
this.gotoBlock = gotoBlock;
}
+ /**
+ * @return the phase index entry this phase was loaded from, or null when the
+ * phase was loaded without an index
+ */
+ public PhaseIndexEntry getIndexEntry() {
+ return indexEntry;
+ }
+
+ /**
+ * @param indexEntry the phase index entry this phase was loaded from
+ */
+ public void setIndexEntry(PhaseIndexEntry indexEntry) {
+ this.indexEntry = indexEntry;
+ }
+
+ /**
+ * @return the minimum Minecraft version this phase needs, or null if it can run
+ * on any version
+ */
+ public String getRequiredMinecraftVersion() {
+ return requiredMinecraftVersion;
+ }
+
+ /**
+ * @param requiredMinecraftVersion the minimum Minecraft version this phase
+ * needs, e.g. "26.2"
+ */
+ public void setRequiredMinecraftVersion(String requiredMinecraftVersion) {
+ this.requiredMinecraftVersion = requiredMinecraftVersion;
+ }
+
/**
* @return the total
*/
diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java
index a9aafd0..5081e6c 100644
--- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java
+++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java
@@ -3,11 +3,14 @@
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -17,9 +20,12 @@
import java.util.Optional;
import java.util.TreeMap;
import java.util.jar.JarFile;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.math.NumberUtils;
+import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.block.Biome;
@@ -27,11 +33,15 @@
import io.papermc.paper.registry.RegistryAccess;
import io.papermc.paper.registry.RegistryKey;
import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.EntityType;
import org.bukkit.inventory.ItemStack;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
import com.google.common.base.Enums;
import com.google.common.io.Files;
@@ -72,11 +82,26 @@ public class OneBlocksManager {
private static final String END_COMMANDS = "end-commands";
private static final String END_COMMANDS_FIRST_TIME = "end-commands-first-time";
private static final String REQUIREMENTS = "requirements";
+ private static final String REQUIRED_MC_VERSION = "requiredMinecraftVersion";
+ private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)*)");
+ private static final String PHASES_INDEX_YML = "phases_index.yml";
+ private static final String INDEX_PHASES = "phases";
+ private static final String GOTO_AT_END = "gotoAtEnd";
+ private static final String CHESTS_YML_SUFFIX = "_chests.yml";
+ private static final String WEIGHT = "weight";
+ /**
+ * Length used for a phase whose index entry has no valid length.
+ */
+ public static final int DEFAULT_PHASE_LENGTH = 500;
+ private static final FilenameFilter YML_FILTER = (dir, name) -> name.toLowerCase(Locale.ENGLISH)
+ .endsWith(".yml");
private static final String BLOCK = "Block ";
private static final String BUT_ALREADY_SET_TO = " but already set to ";
private static final String DUPLICATE = " Duplicate phase file?";
private final AOneBlock addon;
private TreeMap blockProbs;
+ private List phaseIndex = new ArrayList<>();
+ private Integer gotoAtEnd;
/**
* @param addon - addon
@@ -93,8 +118,10 @@ public OneBlocksManager(AOneBlock addon) {
* @throws IOException - if config file has bad syntax or migration fails
*/
public void loadPhases() throws IOException {
- // Clear block probabilities
+ // Clear block probabilities and the index model
blockProbs = new TreeMap<>();
+ phaseIndex = new ArrayList<>();
+ gotoAtEnd = null;
// Check for folder
File check = new File(addon.getDataFolder(), PHASES);
if (check.mkdirs()) {
@@ -115,14 +142,449 @@ public void loadPhases() throws IOException {
copyPhasesFromAddonJar(check);
}
}
- // Get files in folder
- // Filter for files ending with .yml
- FilenameFilter ymlFilter = (dir, name) -> name.toLowerCase(java.util.Locale.ENGLISH).endsWith(".yml");
- for (File phaseFile : Objects.requireNonNull(check.listFiles(ymlFilter))) {
+ // Phase index. It is read before any phase file so that phases needing a
+ // newer server version are skipped without their files ever being parsed.
+ File indexFile = new File(addon.getDataFolder(), PHASES_INDEX_YML);
+ if (!indexFile.exists()) {
+ copyIndexFromAddonJar();
+ }
+ if (!indexFile.exists()) {
+ // Legacy install - build the index from the phase files on disk
+ generateIndex(check, indexFile);
+ }
+ if (indexFile.exists() && loadPhasesFromIndex(indexFile, check)) {
+ return;
+ }
+ // Fallback - load every phase file directly as before the index existed
+ File[] phaseFiles = Objects.requireNonNull(check.listFiles(YML_FILTER));
+ if (phaseFiles.length > 0) {
+ addon.logWarning("Phase index could not be used - loading phase files directly.");
+ }
+ for (File phaseFile : phaseFiles) {
loadPhase(phaseFile);
}
}
+ /**
+ * Copies the shipped phase index from the addon jar, if it has one.
+ */
+ private void copyIndexFromAddonJar() {
+ try {
+ addon.saveResource(PHASES_INDEX_YML, false);
+ } catch (Exception e) {
+ // Not in the jar - the index will be generated from the phase files
+ }
+ }
+
+ /**
+ * Builds a phase index from the phase files already on disk. Only main phase
+ * files are read - they contain plain scalars, so this is safe on any server
+ * version. Chest files, which hold serialized items, are never touched.
+ * Lengths are derived from the gaps between consecutive start blocks.
+ *
+ * @param phaseFolder folder holding the phase files
+ * @param indexFile index file to write
+ */
+ void generateIndex(File phaseFolder, File indexFile) {
+ TreeMap scan = new TreeMap<>();
+ Integer gotoTarget = null;
+ int gotoStart = -1;
+ FilenameFilter mainYmlFilter = (dir, name) -> name.toLowerCase(Locale.ENGLISH).endsWith(".yml")
+ && !name.toLowerCase(Locale.ENGLISH).endsWith(CHESTS_YML_SUFFIX);
+ File[] files = phaseFolder.listFiles(mainYmlFilter);
+ if (files == null) {
+ return;
+ }
+ for (File phaseFile : files) {
+ YamlConfiguration cfg = new YamlConfiguration();
+ try {
+ cfg.load(phaseFile);
+ } catch (Exception e) {
+ addon.logError("Could not scan " + phaseFile.getName() + " for the phase index: " + e.getMessage());
+ continue;
+ }
+ String base = phaseFile.getName().substring(0, phaseFile.getName().length() - ".yml".length());
+ scanPhaseFile(cfg, base, scan);
+ for (String key : cfg.getKeys(false)) {
+ ConfigurationSection section = cfg.getConfigurationSection(key);
+ if (NumberUtils.isCreatable(key) && section != null && section.contains(GOTO_BLOCK)) {
+ gotoTarget = section.getInt(GOTO_BLOCK, 0);
+ gotoStart = Integer.parseInt(key);
+ }
+ }
+ }
+ if (scan.isEmpty()) {
+ return;
+ }
+ List entries = new ArrayList<>();
+ List starts = new ArrayList<>(scan.keySet());
+ for (int i = 0; i < starts.size(); i++) {
+ int start = starts.get(i);
+ int next = i + 1 < starts.size() ? starts.get(i + 1) : gotoStart;
+ PhaseIndexEntry entry = scan.get(start);
+ entry.setLength(next > start ? next - start : DEFAULT_PHASE_LENGTH);
+ entries.add(entry);
+ }
+ if (writeIndex(indexFile, entries, gotoTarget)) {
+ addon.log("Created " + PHASES_INDEX_YML + " from the existing phase files.");
+ }
+ }
+
+ /**
+ * Adds an index entry for every non-goto phase section in a scanned file.
+ *
+ * @param cfg loaded main phase file
+ * @param base file name without the .yml extension
+ * @param scan map of start block to index entry being built
+ */
+ private void scanPhaseFile(YamlConfiguration cfg, String base, TreeMap scan) {
+ for (String key : cfg.getKeys(false)) {
+ if (!NumberUtils.isCreatable(key)) {
+ continue;
+ }
+ ConfigurationSection section = cfg.getConfigurationSection(key);
+ if (section == null || section.contains(GOTO_BLOCK)) {
+ continue;
+ }
+ PhaseIndexEntry entry = new PhaseIndexEntry();
+ entry.setFile(base);
+ entry.setSection(key);
+ entry.setName(section.getString(NAME, base));
+ String requiredVersion = Objects.toString(section.get(REQUIRED_MC_VERSION), "");
+ if (!requiredVersion.isEmpty()) {
+ entry.setRequiredMinecraftVersion(requiredVersion);
+ }
+ scan.put(Integer.parseInt(key), entry);
+ }
+ }
+
+ /**
+ * Writes a phase index file.
+ *
+ * @param indexFile file to write
+ * @param entries index entries, in phase order
+ * @param gotoTarget block count to jump to after the last phase, or null
+ * @return true if written
+ */
+ private boolean writeIndex(File indexFile, List entries, @Nullable Integer gotoTarget) {
+ YamlConfiguration index = new YamlConfiguration();
+ index.set(INDEX_PHASES, entries.stream().map(PhaseIndexEntry::toMap).toList());
+ if (gotoTarget != null) {
+ index.set(GOTO_AT_END, gotoTarget);
+ }
+ try {
+ index.save(indexFile);
+ return true;
+ } catch (IOException e) {
+ addon.logError("Could not save " + PHASES_INDEX_YML + " " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Saves the in-memory phase index to disk. Call after changing the order,
+ * lengths, or enabled state of entries from {@link #getPhaseIndex()}, then
+ * call {@link #loadPhases()} to apply the changes.
+ *
+ * @return true if written
+ */
+ public boolean saveIndex() {
+ return writeIndex(new File(addon.getDataFolder(), PHASES_INDEX_YML), phaseIndex, gotoAtEnd);
+ }
+
+ /**
+ * @return the live phase index, in phase order, including entries that are
+ * disabled or skipped on this server version. Mutate it and call
+ * {@link #saveIndex()} then {@link #loadPhases()} to apply changes.
+ * Empty when phases were loaded without an index.
+ */
+ public List getPhaseIndex() {
+ return phaseIndex;
+ }
+
+ /**
+ * Checks whether an index entry would load on this server - it is enabled and
+ * the server meets its required Minecraft version.
+ *
+ * @param entry index entry
+ * @return true if the phase would load
+ */
+ public boolean isPhaseAvailable(PhaseIndexEntry entry) {
+ String requiredVersion = Objects.toString(entry.getRequiredMinecraftVersion(), "");
+ return entry.isEnabled()
+ && (requiredVersion.isEmpty() || isVersionAtLeast(Bukkit.getMinecraftVersion(), requiredVersion));
+ }
+
+ /**
+ * @return block count the game jumps to after the last phase, or null if
+ * there is no jump
+ */
+ @Nullable
+ public Integer getGotoAtEnd() {
+ return gotoAtEnd;
+ }
+
+ /**
+ * @param gotoAtEnd block count to jump to after the last phase, or null for
+ * no jump
+ */
+ public void setGotoAtEnd(@Nullable Integer gotoAtEnd) {
+ this.gotoAtEnd = gotoAtEnd;
+ }
+
+ /**
+ * Loads the phases listed in the index, in order. Start blocks are the running
+ * sum of the lengths of the phases loaded so far, so a skipped phase takes up
+ * no blocks and the ones after it shift down to fill the gap.
+ *
+ * @param indexFile index file
+ * @param phaseFolder folder holding the phase files
+ * @return true if the index was usable, false to fall back to direct loading
+ */
+ boolean loadPhasesFromIndex(File indexFile, File phaseFolder) {
+ YamlConfiguration index = new YamlConfiguration();
+ try {
+ index.load(indexFile);
+ } catch (Exception e) {
+ addon.logError("Could not load " + PHASES_INDEX_YML + ": " + e.getMessage());
+ return false;
+ }
+ List entries = new ArrayList<>();
+ for (Map, ?> map : index.getMapList(INDEX_PHASES)) {
+ PhaseIndexEntry entry = PhaseIndexEntry.fromMap(map);
+ if (entry == null) {
+ addon.logError(PHASES_INDEX_YML + " entry is missing the file name. Skipping it.");
+ } else {
+ entries.add(entry);
+ }
+ }
+ if (entries.isEmpty()) {
+ addon.logError(PHASES_INDEX_YML + " contains no phases.");
+ return false;
+ }
+ phaseIndex = entries;
+ gotoAtEnd = index.contains(GOTO_AT_END) ? index.getInt(GOTO_AT_END, 0) : null;
+ int startBlock = 0;
+ for (PhaseIndexEntry entry : entries) {
+ startBlock = loadIndexedPhase(phaseFolder, entry, startBlock);
+ }
+ if (gotoAtEnd != null) {
+ OneBlockPhase gotoPhase = new OneBlockPhase(String.valueOf(startBlock));
+ gotoPhase.setGotoBlock(gotoAtEnd);
+ blockProbs.put(startBlock, gotoPhase);
+ }
+ return true;
+ }
+
+ /**
+ * Loads one indexed phase.
+ *
+ * @param phaseFolder folder holding the phase files
+ * @param entry index entry
+ * @param startBlock start block for this phase
+ * @return the start block for the next phase - unchanged if this one was
+ * skipped
+ */
+ private int loadIndexedPhase(File phaseFolder, PhaseIndexEntry entry, int startBlock) {
+ String name = entry.getName();
+ if (!entry.isEnabled()) {
+ addon.log("Skipping phase " + name + ": disabled in " + PHASES_INDEX_YML + ".");
+ return startBlock;
+ }
+ String requiredVersion = Objects.toString(entry.getRequiredMinecraftVersion(), "");
+ if (!requiredVersion.isEmpty() && !isVersionAtLeast(Bukkit.getMinecraftVersion(), requiredVersion)) {
+ addon.log("Skipping phase " + name + ": it requires Minecraft " + requiredVersion + " or later.");
+ return startBlock;
+ }
+ File mainFile = new File(phaseFolder, entry.getFile() + ".yml");
+ if (!mainFile.exists()) {
+ addon.logError(PHASES_INDEX_YML + ": " + mainFile.getName() + " does not exist. Skipping phase " + name
+ + ".");
+ return startBlock;
+ }
+ String blockNumber = String.valueOf(startBlock);
+ OneBlockPhase obPhase = new OneBlockPhase(blockNumber);
+ obPhase.setIndexEntry(entry);
+ if (!requiredVersion.isEmpty()) {
+ obPhase.setRequiredMinecraftVersion(requiredVersion);
+ }
+ try {
+ addon.log("Loading " + mainFile.getName());
+ ConfigurationSection phaseConfig = getPhaseSection(mainFile, entry.getSection());
+ if (phaseConfig == null) {
+ addon.logError(mainFile.getName() + " has no phase section. Skipping phase " + name + ".");
+ return startBlock;
+ }
+ parsePhaseSection(obPhase, phaseConfig, blockNumber);
+ } catch (Exception e) {
+ addon.logError("Could not load phase " + name + ": " + e.getMessage());
+ return startBlock;
+ }
+ loadIndexedChests(phaseFolder, entry.getFile(), entry.getSection(), obPhase, name);
+ blockProbs.put(startBlock, obPhase);
+ return startBlock + phaseLength(entry);
+ }
+
+ /**
+ * Loads the chest file for an indexed phase, if there is one. The file is read
+ * with plain SnakeYAML - not YamlConfiguration - so serialized items are never
+ * eagerly deserialized. Each item is built individually and an item that this
+ * server version does not know is skipped with a log line instead of a stack
+ * trace. A broken chest file loses its chests but does not lose the phase.
+ */
+ private void loadIndexedChests(File phaseFolder, String fileBase, String section, OneBlockPhase obPhase,
+ String name) {
+ File chestFile = new File(phaseFolder, fileBase + CHESTS_YML_SUFFIX);
+ if (!chestFile.exists()) {
+ return;
+ }
+ try (Reader reader = java.nio.file.Files.newBufferedReader(chestFile.toPath(), StandardCharsets.UTF_8)) {
+ addon.log("Loading " + chestFile.getName());
+ Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()));
+ Object phaseMap = getRawSection(yaml.load(reader), section);
+ if (phaseMap instanceof Map, ?> map && map.get(CHESTS) instanceof Map, ?> chests) {
+ addChestsFromRaw(obPhase, chests, chestFile.getName());
+ }
+ } catch (Exception e) {
+ addon.logError("Could not load chests for phase " + name + ": " + e.getMessage());
+ }
+ }
+
+ /**
+ * Gets the named section of a raw YAML map, or its first map value if the
+ * named one is absent.
+ */
+ private Object getRawSection(Object root, String section) {
+ if (!(root instanceof Map, ?> rootMap)) {
+ return null;
+ }
+ if (section != null) {
+ for (Entry, ?> en : rootMap.entrySet()) {
+ if (section.equals(Objects.toString(en.getKey()))) {
+ return en.getValue();
+ }
+ }
+ }
+ return rootMap.values().stream().filter(Map.class::isInstance).findFirst().orElse(null);
+ }
+
+ /**
+ * Adds the chests defined in a raw chests map to the phase.
+ *
+ * @param obPhase phase to add chests to
+ * @param chests raw map of chest id to chest definition
+ * @param fileName file the chests came from, for log messages
+ */
+ private void addChestsFromRaw(OneBlockPhase obPhase, Map, ?> chests, String fileName) {
+ for (Object chestDef : chests.values()) {
+ if (!(chestDef instanceof Map, ?> chest)) {
+ continue;
+ }
+ Rarity rarity = Enums.getIfPresent(Rarity.class,
+ Objects.toString(chest.get(RARITY), "COMMON").toUpperCase(Locale.ENGLISH)).or(Rarity.COMMON);
+ Map items = new HashMap<>();
+ if (chest.get(CONTENTS) instanceof Map, ?> contents) {
+ for (Entry, ?> slotEntry : contents.entrySet()) {
+ if (!NumberUtils.isCreatable(Objects.toString(slotEntry.getKey()))
+ || !(slotEntry.getValue() instanceof Map, ?> rawItem)) {
+ continue;
+ }
+ ItemStack item = chestItem(rawItem, fileName);
+ if (item != null) {
+ items.put(Integer.parseInt(Objects.toString(slotEntry.getKey())), item);
+ }
+ }
+ }
+ obPhase.addChest(items, rarity);
+ }
+ }
+
+ /**
+ * Builds one chest item from its raw serialized map. Items whose id is not in
+ * this server's registry are skipped with a log line. Everything else is
+ * handed to Bukkit's deserializer, with failures contained to the one item.
+ *
+ * @param raw raw serialized item map
+ * @param fileName file the item came from, for log messages
+ * @return the item, or null if it cannot exist on this server
+ */
+ private @Nullable ItemStack chestItem(Map, ?> raw, String fileName) {
+ String id = Objects.toString(raw.get("id"), Objects.toString(raw.get("type"), null));
+ if (id != null && Material.matchMaterial(id) == null) {
+ addon.log("Skipping item " + id + " in " + fileName + ": it does not exist on this server version.");
+ return null;
+ }
+ try {
+ Map map = new LinkedHashMap<>();
+ raw.forEach((k, v) -> map.put(Objects.toString(k), v));
+ map.remove("==");
+ return ItemStack.deserialize(map);
+ } catch (Exception e) {
+ addon.log("Skipping item " + id + " in " + fileName + ": " + e.getMessage());
+ return null;
+ }
+ }
+
+ private int phaseLength(PhaseIndexEntry entry) {
+ if (entry.getLength() > 0) {
+ return entry.getLength();
+ }
+ addon.logWarning("Phase " + entry.getName() + " has no valid length in " + PHASES_INDEX_YML + ". Using "
+ + DEFAULT_PHASE_LENGTH + ".");
+ return DEFAULT_PHASE_LENGTH;
+ }
+
+ /**
+ * Gets the named section of a phase file, or its first section if the named
+ * one is absent.
+ */
+ private ConfigurationSection getPhaseSection(File file, String section) throws IOException, InvalidConfigurationException {
+ YamlConfiguration cfg = new YamlConfiguration();
+ cfg.load(file);
+ if (section != null && cfg.isConfigurationSection(section)) {
+ return cfg.getConfigurationSection(section);
+ }
+ return cfg.getKeys(false).stream().map(cfg::getConfigurationSection).filter(Objects::nonNull).findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * Check that a server Minecraft version is the same as or newer than a required
+ * version. Only the leading dotted-numeric part of each string is compared, so
+ * "1.21.11-R0.1-SNAPSHOT" is read as 1.21.11; missing components count as 0.
+ *
+ * @param serverVersion version the server is running, e.g. "1.21.11" or "26.2"
+ * @param requiredVersion minimum version needed
+ * @return true if serverVersion is at least requiredVersion; false if it is
+ * older or if either version cannot be parsed
+ */
+ static boolean isVersionAtLeast(String serverVersion, String requiredVersion) {
+ int[] server = parseVersion(serverVersion);
+ int[] required = parseVersion(requiredVersion);
+ if (server.length == 0 || required.length == 0) {
+ return false;
+ }
+ for (int i = 0; i < Math.max(server.length, required.length); i++) {
+ int s = i < server.length ? server[i] : 0;
+ int r = i < required.length ? required[i] : 0;
+ if (s != r) {
+ return s > r;
+ }
+ }
+ return true;
+ }
+
+ private static int[] parseVersion(String version) {
+ if (version == null) {
+ return new int[0];
+ }
+ Matcher matcher = VERSION_PATTERN.matcher(version.trim());
+ if (!matcher.find()) {
+ return new int[0];
+ }
+ return Arrays.stream(matcher.group(1).split("\\.")).mapToInt(Integer::parseInt).toArray();
+ }
+
/**
* Copies phase files from the addon jar to the file system
*
@@ -149,31 +611,54 @@ private void loadPhase(File phaseFile) throws IOException {
}
for (String phaseStartBlockNumKey : oneBlocks.getKeys(false)) {
Integer phaseStartBlockNum = Integer.valueOf(phaseStartBlockNumKey);
- OneBlockPhase obPhase = blockProbs.computeIfAbsent(phaseStartBlockNum,
- k -> new OneBlockPhase(phaseStartBlockNumKey));
// Get config Section
ConfigurationSection phaseConfig = oneBlocks.getConfigurationSection(phaseStartBlockNumKey);
+ // Skip phases that need a newer Minecraft version than this server runs
+ String requiredVersion = Objects.toString(phaseConfig.get(REQUIRED_MC_VERSION), "");
+ if (!requiredVersion.isEmpty() && !isVersionAtLeast(Bukkit.getMinecraftVersion(), requiredVersion)) {
+ addon.log("Skipping phase " + phaseStartBlockNumKey + " in " + phaseFile.getName()
+ + ": it requires Minecraft " + requiredVersion + " or later.");
+ continue;
+ }
+ OneBlockPhase obPhase = blockProbs.computeIfAbsent(phaseStartBlockNum,
+ k -> new OneBlockPhase(phaseStartBlockNumKey));
+ if (!requiredVersion.isEmpty()) {
+ obPhase.setRequiredMinecraftVersion(requiredVersion);
+ }
// goto
if (phaseConfig.contains(GOTO_BLOCK)) {
obPhase.setGotoBlock(phaseConfig.getInt(GOTO_BLOCK, 0));
continue;
}
- initBlock(phaseStartBlockNumKey, obPhase, phaseConfig);
- // Blocks
- addBlocks(obPhase, phaseConfig);
- // Mobs
- addMobs(obPhase, phaseConfig);
- // Chests
- addChests(obPhase, phaseConfig);
- // Commands
- addCommands(obPhase, phaseConfig);
- // Requirements
- addRequirements(obPhase, phaseConfig);
+ parsePhaseSection(obPhase, phaseConfig, phaseStartBlockNumKey);
// Add to the map
blockProbs.put(phaseStartBlockNum, obPhase);
}
}
+ /**
+ * Parses everything in one phase section into the phase.
+ *
+ * @param obPhase phase to fill
+ * @param phaseConfig configuration section being read
+ * @param blockNumber string representation of this phase's start block
+ * @throws IOException if there's an error in the config file
+ */
+ void parsePhaseSection(OneBlockPhase obPhase, ConfigurationSection phaseConfig, String blockNumber)
+ throws IOException {
+ initBlock(blockNumber, obPhase, phaseConfig);
+ // Blocks
+ addBlocks(obPhase, phaseConfig);
+ // Mobs
+ addMobs(obPhase, phaseConfig);
+ // Chests
+ addChests(obPhase, phaseConfig);
+ // Commands
+ addCommands(obPhase, phaseConfig);
+ // Requirements
+ addRequirements(obPhase, phaseConfig);
+ }
+
/**
* Load in the phase's init
*
@@ -480,6 +965,17 @@ void addMobs(OneBlockPhase obPhase, ConfigurationSection phase) throws IOExcepti
}
ConfigurationSection mobs = phase.getConfigurationSection(MOBS);
for (String entity : mobs.getKeys(false)) {
+ int weight;
+ if (mobs.isConfigurationSection(entity)) {
+ // Object form - a weight plus an optional required version
+ ConfigurationSection def = Objects.requireNonNull(mobs.getConfigurationSection(entity));
+ if (entryIsForNewerServer(def, "mob " + entity, obPhase)) {
+ continue;
+ }
+ weight = def.getInt(WEIGHT, 0);
+ } else {
+ weight = mobs.getInt(entity, 0);
+ }
EntityType et = resolveEntityType(entity.toUpperCase(Locale.ENGLISH));
if (et == null) {
addon.logError("Bad entity type in " + obPhase.getPhaseName() + ": " + entity);
@@ -488,10 +984,30 @@ void addMobs(OneBlockPhase obPhase, ConfigurationSection phase) throws IOExcepti
.filter(EntityType::isAlive).map(EntityType::name).collect(Collectors.joining(",")));
return;
}
- processMobEntry(obPhase, mobs, entity, et);
+ processMobEntry(obPhase, entity, et, weight);
}
}
+ /**
+ * Checks the optional required version of an object-form block or mob entry.
+ * A gated entry is skipped with a log line so phases can mix content from
+ * different Minecraft versions without errors on older servers.
+ *
+ * @param def the entry's configuration section
+ * @param what description of the entry for the log message
+ * @param obPhase phase being loaded
+ * @return true if the entry needs a newer server than this one
+ */
+ private boolean entryIsForNewerServer(ConfigurationSection def, String what, OneBlockPhase obPhase) {
+ String requiredVersion = Objects.toString(def.get(REQUIRED_MC_VERSION), "");
+ if (!requiredVersion.isEmpty() && !isVersionAtLeast(Bukkit.getMinecraftVersion(), requiredVersion)) {
+ addon.log("Skipping " + what + " in " + obPhase.getPhaseName() + ": it requires Minecraft "
+ + requiredVersion + " or later.");
+ return true;
+ }
+ return false;
+ }
+
private EntityType resolveEntityType(String name) {
// Pig zombie handling: accept both legacy and current name
if (name.equals("PIG_ZOMBIE") || name.equals("ZOMBIFIED_PIGLIN")) {
@@ -501,13 +1017,13 @@ private EntityType resolveEntityType(String name) {
return Enums.getIfPresent(EntityType.class, name).orNull();
}
- private void processMobEntry(OneBlockPhase obPhase, ConfigurationSection mobs, String entity, EntityType et) {
+ private void processMobEntry(OneBlockPhase obPhase, String entity, EntityType et, int weight) {
if (!et.isSpawnable() || !et.isAlive()) {
addon.logError("Entity type is not spawnable " + obPhase.getPhaseName() + ": " + entity);
return;
}
- if (mobs.getInt(entity) > 0) {
- obPhase.addMob(et, mobs.getInt(entity));
+ if (weight > 0) {
+ obPhase.addMob(et, weight);
} else {
addon.logWarning("Bad entity weight for " + obPhase.getPhaseName() + ": " + entity
+ ". Must be positive number above 1.");
@@ -518,7 +1034,16 @@ void addBlocks(OneBlockPhase obPhase, ConfigurationSection phase) {
if (phase.isConfigurationSection(BLOCKS)) {
ConfigurationSection blocks = phase.getConfigurationSection(BLOCKS);
for (String material : blocks.getKeys(false)) {
- addMaterial(obPhase, material, Objects.toString(blocks.get(material)));
+ if (blocks.isConfigurationSection(material)) {
+ // Object form - a weight plus an optional required version
+ ConfigurationSection def = Objects.requireNonNull(blocks.getConfigurationSection(material));
+ if (entryIsForNewerServer(def, "block " + material, obPhase)) {
+ continue;
+ }
+ addMaterial(obPhase, material, Objects.toString(def.get(WEIGHT)));
+ } else {
+ addMaterial(obPhase, material, Objects.toString(blocks.get(material)));
+ }
}
} else if (phase.isList(BLOCKS)) {
for (Map, ?> map : phase.getMapList(BLOCKS)) {
@@ -640,8 +1165,15 @@ public boolean saveOneBlockConfig() {
// Go through each phase
boolean success = true;
for (OneBlockPhase p : blockProbs.values()) {
+ if (p.isGotoPhase() && !phaseIndex.isEmpty()) {
+ // Indexed goto phases are synthetic - gotoAtEnd in the index covers them
+ continue;
+ }
success = savePhase(p);
}
+ if (!phaseIndex.isEmpty()) {
+ success = saveIndex() && success;
+ }
return success;
}
@@ -666,7 +1198,10 @@ public boolean savePhase(OneBlockPhase p) {
private boolean saveMainPhase(OneBlockPhase p) {
YamlConfiguration oneBlocks = new YamlConfiguration();
- ConfigurationSection phSec = oneBlocks.createSection(p.getBlockNumber());
+ ConfigurationSection phSec = oneBlocks.createSection(getPhaseSectionKey(p));
+ if (p.getRequiredMinecraftVersion() != null) {
+ phSec.set(REQUIRED_MC_VERSION, p.getRequiredMinecraftVersion());
+ }
// Check for a goto block
if (p.isGotoPhase()) {
phSec.set(GOTO_BLOCK, p.getGotoBlock());
@@ -712,7 +1247,10 @@ private void saveHolos(ConfigurationSection phSec, OneBlockPhase p) {
private boolean saveChestPhase(OneBlockPhase p) {
YamlConfiguration oneBlocks = new YamlConfiguration();
- ConfigurationSection phSec = oneBlocks.createSection(p.getBlockNumber());
+ ConfigurationSection phSec = oneBlocks.createSection(getPhaseSectionKey(p));
+ if (p.getRequiredMinecraftVersion() != null) {
+ phSec.set(REQUIRED_MC_VERSION, p.getRequiredMinecraftVersion());
+ }
saveChests(phSec, p);
try {
// Save
@@ -727,6 +1265,11 @@ private boolean saveChestPhase(OneBlockPhase p) {
}
private String getPhaseFileName(OneBlockPhase p) {
+ if (p.getIndexEntry() != null) {
+ // The file base name is the phase's identity and stays stable no matter
+ // where the phase currently starts
+ return p.getIndexEntry().getFile();
+ }
if (p.isGotoPhase()) {
return p.getBlockNumber() + "_goto_" + p.getGotoBlock();
}
@@ -734,6 +1277,17 @@ private String getPhaseFileName(OneBlockPhase p) {
+ (p.getPhaseName() == null ? "" : p.getPhaseName().toLowerCase().replace(' ', '_'));
}
+ /**
+ * @return the top-level YAML key to save a phase under - the stable section
+ * key from the index when there is one, otherwise the start block
+ */
+ private String getPhaseSectionKey(OneBlockPhase p) {
+ if (p.getIndexEntry() != null && p.getIndexEntry().getSection() != null) {
+ return p.getIndexEntry().getSection();
+ }
+ return p.getBlockNumber();
+ }
+
private void saveChests(ConfigurationSection phSec, OneBlockPhase phase) {
ConfigurationSection chests = phSec.createSection(CHESTS);
int index = 1;
diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/PhaseIndexEntry.java b/src/main/java/world/bentobox/aoneblock/oneblocks/PhaseIndexEntry.java
new file mode 100644
index 0000000..0229ff3
--- /dev/null
+++ b/src/main/java/world/bentobox/aoneblock/oneblocks/PhaseIndexEntry.java
@@ -0,0 +1,166 @@
+package world.bentobox.aoneblock.oneblocks;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * One entry of the phase index (phases_index.yml). The index defines which
+ * phases load, in what order, and how many blocks each phase runs for. Start
+ * blocks are computed by summing the lengths of the phases loaded before an
+ * entry, so entries can be reordered freely and a skipped phase takes up no
+ * blocks.
+ *
+ * @author tastybento
+ */
+public class PhaseIndexEntry {
+
+ private static final String FILE = "file";
+ private static final String SECTION = "section";
+ private static final String NAME = "name";
+ private static final String LENGTH = "length";
+ private static final String ENABLED = "enabled";
+ private static final String REQUIRED_MC_VERSION = "requiredMinecraftVersion";
+
+ private String file;
+ private String section;
+ private String name;
+ private int length;
+ private boolean enabled = true;
+ private String requiredMinecraftVersion;
+
+ /**
+ * Reads an entry from its raw YAML map.
+ *
+ * @param map raw map from the index file
+ * @return entry, or null if the map has no file name
+ */
+ @Nullable
+ public static PhaseIndexEntry fromMap(Map, ?> map) {
+ String file = Objects.toString(map.get(FILE), null);
+ if (file == null) {
+ return null;
+ }
+ PhaseIndexEntry entry = new PhaseIndexEntry();
+ entry.file = file;
+ entry.section = Objects.toString(map.get(SECTION), null);
+ entry.name = Objects.toString(map.get(NAME), file);
+ entry.length = map.get(LENGTH) instanceof Number number ? number.intValue() : 0;
+ entry.enabled = !Boolean.FALSE.equals(map.get(ENABLED));
+ String version = Objects.toString(map.get(REQUIRED_MC_VERSION), "");
+ entry.requiredMinecraftVersion = version.isEmpty() ? null : version;
+ return entry;
+ }
+
+ /**
+ * @return this entry as a map for writing to the index file
+ */
+ public Map toMap() {
+ Map map = new LinkedHashMap<>();
+ map.put(FILE, file);
+ if (section != null) {
+ map.put(SECTION, section);
+ }
+ map.put(NAME, name);
+ map.put(LENGTH, length);
+ if (!enabled) {
+ map.put(ENABLED, false);
+ }
+ if (requiredMinecraftVersion != null) {
+ map.put(REQUIRED_MC_VERSION, requiredMinecraftVersion);
+ }
+ return map;
+ }
+
+ /**
+ * @return base name of the phase file, without the .yml extension. The chest
+ * file is this plus _chests.yml. This is the phase's identity.
+ */
+ public String getFile() {
+ return file;
+ }
+
+ /**
+ * @param file base name of the phase file, without the .yml extension
+ */
+ public void setFile(String file) {
+ this.file = file;
+ }
+
+ /**
+ * @return the top-level key inside the phase file, or null to use the file's
+ * first section
+ */
+ @Nullable
+ public String getSection() {
+ return section;
+ }
+
+ /**
+ * @param section the top-level key inside the phase file
+ */
+ public void setSection(String section) {
+ this.section = section;
+ }
+
+ /**
+ * @return display name, used in logs and admin tools
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @param name display name
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @return number of blocks this phase runs for
+ */
+ public int getLength() {
+ return length;
+ }
+
+ /**
+ * @param length number of blocks this phase runs for
+ */
+ public void setLength(int length) {
+ this.length = length;
+ }
+
+ /**
+ * @return false if the phase should not be loaded
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * @param enabled false to leave the phase out
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * @return the minimum Minecraft version this phase needs, or null if it can
+ * run on any version
+ */
+ @Nullable
+ public String getRequiredMinecraftVersion() {
+ return requiredMinecraftVersion;
+ }
+
+ /**
+ * @param requiredMinecraftVersion the minimum Minecraft version this phase
+ * needs, e.g. "26.2"
+ */
+ public void setRequiredMinecraftVersion(String requiredMinecraftVersion) {
+ this.requiredMinecraftVersion = requiredMinecraftVersion;
+ }
+}
diff --git a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java
new file mode 100644
index 0000000..a320a0d
--- /dev/null
+++ b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java
@@ -0,0 +1,248 @@
+package world.bentobox.aoneblock.panels;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+
+import world.bentobox.aoneblock.AOneBlock;
+import world.bentobox.aoneblock.oneblocks.OneBlockPhase;
+import world.bentobox.aoneblock.oneblocks.OneBlocksManager;
+import world.bentobox.aoneblock.oneblocks.PhaseIndexEntry;
+import world.bentobox.bentobox.api.panels.PanelItem;
+import world.bentobox.bentobox.api.panels.builders.PanelBuilder;
+import world.bentobox.bentobox.api.panels.builders.PanelItemBuilder;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Admin panel for editing the phase order. Clicking a phase picks it up - the
+ * remaining phases shrink left to fill the gap. Clicking a phase in the row
+ * shoves it and everything after it right and drops the held phase there;
+ * clicking the slot after the last phase drops it at the end. Clicking
+ * anywhere else, or closing the panel, puts the held phase back. Right-click
+ * toggles a phase on or off. Changes are saved to the phase index and applied
+ * immediately.
+ *
+ * @author tastybento
+ */
+public class AdminPhasesPanel {
+
+ private static final String REF = "aoneblock.commands.admin.phases.gui.";
+ private static final String NAME_PLACEHOLDER = "[name]";
+ private static final String NUMBER_PLACEHOLDER = "[number]";
+ private static final int HAND_SLOT = 4;
+ private static final int ROW_START = 9;
+ private static final int PANEL_SIZE = 54;
+
+ private final AOneBlock addon;
+ private final User user;
+ /**
+ * Position in the phase index of the phase currently picked up, or -1.
+ */
+ private int heldIndex = -1;
+
+ AdminPhasesPanel(AOneBlock addon, User user) {
+ this.addon = addon;
+ this.user = user;
+ }
+
+ /**
+ * Opens the phase order panel for an admin.
+ *
+ * @param addon addon
+ * @param user admin viewing the panel
+ */
+ public static void openPanel(AOneBlock addon, User user) {
+ new AdminPhasesPanel(addon, user).build();
+ }
+
+ private OneBlocksManager manager() {
+ return addon.getOneBlockManager();
+ }
+
+ void build() {
+ PanelBuilder pb = new PanelBuilder().user(user).size(PANEL_SIZE)
+ .name(user.getTranslation(REF + "title"));
+ List index = manager().getPhaseIndex();
+ // Hand slot - the held phase, or how-to-use info
+ pb.item(HAND_SLOT, heldIndex >= 0 ? handItem(index.get(heldIndex)) : infoItem());
+ // Phase row - without the held phase, so the rest shrink to fill the gap
+ List displayed = new ArrayList<>(index);
+ PhaseIndexEntry held = heldIndex >= 0 ? displayed.remove(heldIndex) : null;
+ int slot = ROW_START;
+ int startBlock = 0;
+ for (int i = 0; i < displayed.size() && slot < PANEL_SIZE; i++, slot++) {
+ PhaseIndexEntry entry = displayed.get(i);
+ pb.item(slot, phaseItem(entry, i, startBlock, held != null));
+ if (manager().isPhaseAvailable(entry)) {
+ startBlock += entry.getLength() > 0 ? entry.getLength() : OneBlocksManager.DEFAULT_PHASE_LENGTH;
+ }
+ }
+ if (held != null && slot < PANEL_SIZE) {
+ pb.item(slot++, dropAtEndItem(displayed.size()));
+ }
+ // Fillers catch clicks off the row and put a held phase back
+ for (int i = 0; i < ROW_START; i++) {
+ if (i != HAND_SLOT) {
+ pb.item(i, fillerItem());
+ }
+ }
+ while (slot < PANEL_SIZE) {
+ pb.item(slot++, fillerItem());
+ }
+ pb.build();
+ }
+
+ private PanelItem phaseItem(PhaseIndexEntry entry, int displayIndex, int startBlock, boolean holding) {
+ List description = new ArrayList<>();
+ boolean available = manager().isPhaseAvailable(entry);
+ if (available) {
+ description.add(user.getTranslation(REF + "start", NUMBER_PLACEHOLDER, String.valueOf(startBlock)));
+ }
+ description.add(user.getTranslation(REF + "length", NUMBER_PLACEHOLDER, String.valueOf(entry.getLength())));
+ if (!entry.isEnabled()) {
+ description.add(user.getTranslation(REF + "disabled"));
+ } else if (!available) {
+ description.add(user.getTranslation(REF + "version-locked", "[version]",
+ Objects.toString(entry.getRequiredMinecraftVersion(), "?")));
+ }
+ if (holding) {
+ description.add(user.getTranslation(REF + "drop-here"));
+ } else {
+ description.add(user.getTranslation(REF + "pick-up"));
+ description.add(user.getTranslation(REF + "toggle"));
+ }
+ return new PanelItemBuilder().icon(phaseIcon(entry))
+ .name(user.getTranslation(REF + "phase-name", NAME_PLACEHOLDER, entry.getName()))
+ .description(description)
+ .clickHandler((panel, u, clickType, slot) -> {
+ if (heldIndex >= 0) {
+ dropAt(displayIndex);
+ } else if (clickType.isRightClick()) {
+ toggle(entry);
+ } else {
+ pickUp(displayIndex);
+ }
+ return true;
+ }).build();
+ }
+
+ private PanelItem handItem(PhaseIndexEntry entry) {
+ return new PanelItemBuilder().icon(phaseIcon(entry)).glow(true)
+ .name(user.getTranslation(REF + "held", NAME_PLACEHOLDER, entry.getName()))
+ .description(List.of(user.getTranslation(REF + "put-back")))
+ .clickHandler((panel, u, clickType, slot) -> {
+ putBack();
+ return true;
+ }).build();
+ }
+
+ private PanelItem infoItem() {
+ List description = new ArrayList<>();
+ description.add(user.getTranslation(REF + "instructions"));
+ Integer gotoAtEnd = manager().getGotoAtEnd();
+ if (gotoAtEnd != null) {
+ description.add(user.getTranslation(REF + "repeat", NUMBER_PLACEHOLDER, String.valueOf(gotoAtEnd)));
+ }
+ return new PanelItemBuilder().icon(Material.BOOK).name(user.getTranslation(REF + "info-title"))
+ .description(description).build();
+ }
+
+ private PanelItem dropAtEndItem(int endPosition) {
+ return new PanelItemBuilder().icon(Material.LIME_STAINED_GLASS_PANE)
+ .name(user.getTranslation(REF + "drop-at-end"))
+ .clickHandler((panel, u, clickType, slot) -> {
+ dropAt(endPosition);
+ return true;
+ }).build();
+ }
+
+ private PanelItem fillerItem() {
+ return new PanelItemBuilder().icon(Material.LIGHT_GRAY_STAINED_GLASS_PANE).name(" ")
+ .clickHandler((panel, u, clickType, slot) -> {
+ putBack();
+ return true;
+ }).build();
+ }
+
+ private ItemStack phaseIcon(PhaseIndexEntry entry) {
+ if (!entry.isEnabled()) {
+ return new ItemStack(Material.GRAY_STAINED_GLASS);
+ }
+ if (!manager().isPhaseAvailable(entry)) {
+ return new ItemStack(Material.BARRIER);
+ }
+ return manager().getBlockProbs().values().stream().filter(p -> entry.equals(p.getIndexEntry()))
+ .map(OneBlockPhase::getIconBlock).filter(Objects::nonNull).findFirst().map(ItemStack::clone)
+ .orElseGet(() -> new ItemStack(Material.STONE));
+ }
+
+ /**
+ * Picks up the phase at this position in the index.
+ */
+ void pickUp(int indexPosition) {
+ heldIndex = indexPosition;
+ build();
+ }
+
+ /**
+ * Puts a held phase back where it was without saving anything.
+ */
+ void putBack() {
+ if (heldIndex >= 0) {
+ heldIndex = -1;
+ build();
+ }
+ }
+
+ /**
+ * Drops the held phase at this position among the other phases, shoving the
+ * rest right, then saves and reloads.
+ *
+ * @param displayPosition position in the row of phases shown without the held
+ * one
+ */
+ void dropAt(int displayPosition) {
+ List index = manager().getPhaseIndex();
+ if (heldIndex < 0 || heldIndex >= index.size()) {
+ heldIndex = -1;
+ build();
+ return;
+ }
+ PhaseIndexEntry held = index.remove(heldIndex);
+ index.add(Math.clamp(displayPosition, 0, index.size()), held);
+ heldIndex = -1;
+ persist();
+ }
+
+ /**
+ * Enables or disables a phase, then saves and reloads.
+ */
+ void toggle(PhaseIndexEntry entry) {
+ entry.setEnabled(!entry.isEnabled());
+ persist();
+ }
+
+ /**
+ * Saves the index, reloads the phases so the new order takes effect, and
+ * re-renders the panel from the fresh state.
+ */
+ private void persist() {
+ boolean saved = manager().saveIndex();
+ try {
+ manager().loadPhases();
+ } catch (IOException e) {
+ addon.logError("Could not reload phases: " + e.getMessage());
+ saved = false;
+ }
+ if (saved) {
+ user.sendMessage("aoneblock.commands.admin.phases.saved");
+ } else {
+ user.sendMessage("aoneblock.commands.admin.phases.save-failed");
+ }
+ build();
+ }
+}
diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml
index 9dc91ba..203743b 100755
--- a/src/main/resources/addon.yml
+++ b/src/main/resources/addon.yml
@@ -319,4 +319,7 @@ permissions:
default: OP
aoneblock.admin.sanity:
description: Allow use of '/obadmin sanity' command - display a sanity check of the phase probabilities in the console
+ default: OP
+ aoneblock.admin.phases:
+ description: Allow use of '/obadmin phases' command - open the phase order editor
default: OP
diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml
index 6ef260f..661bd53 100755
--- a/src/main/resources/locales/en-US.yml
+++ b/src/main/resources/locales/en-US.yml
@@ -66,6 +66,27 @@ aoneblock:
parameters: ""
description: "display a sanity check of the phase probabilities in the console"
see-console: "&a See the console for the report"
+ phases:
+ description: "open the phase order editor"
+ no-index: "&c No phase index is loaded, so phases cannot be reordered"
+ saved: "&a Phase order saved and applied"
+ save-failed: "&c Could not save the phase order! See console for errors"
+ gui:
+ title: "&2 Phase Order"
+ info-title: "&f How to use"
+ instructions: "&7 Click a phase to pick it up, then click where it should go. Right-click toggles a phase on or off."
+ repeat: "&7 After the last phase the count jumps to &b [number]"
+ phase-name: "&a [name]"
+ start: "&7 Starts at block &b [number]"
+ length: "&7 Length &b [number] &7 blocks"
+ disabled: "&c Disabled"
+ version-locked: "&c Needs Minecraft [version] or later"
+ pick-up: "&e Click to pick up and move"
+ toggle: "&e Right-click to enable or disable"
+ drop-here: "&e Click to drop the held phase here"
+ drop-at-end: "&a Drop at the end"
+ held: "&e Moving: [name]"
+ put-back: "&e Click to put it back"
count:
description: show the block count and phase
info: "&a You are on block &b [number] in the &a [name] phase"
diff --git a/src/main/resources/phases/15000_sulfur_caves.yml b/src/main/resources/phases/15000_sulfur_caves.yml
new file mode 100644
index 0000000..f7c86ce
--- /dev/null
+++ b/src/main/resources/phases/15000_sulfur_caves.yml
@@ -0,0 +1,55 @@
+# Sulfur Caves phase. The sulfur_caves biome, sulfur/cinnabar blocks and the
+# sulfur cube mob only exist from Minecraft 26.2, so this phase declares a
+# required version and is skipped on older servers.
+'15000':
+ name: Sulfur Caves
+ icon: SULFUR
+ firstBlock: SULFUR
+ biome: SULFUR_CAVES
+ requiredMinecraftVersion: '26.2'
+ fixedBlocks:
+ '0': SULFUR
+ '1': SULFUR
+ '2': CINNABAR
+ '3': STONE
+ '4': DEEPSLATE
+ blocks:
+ SULFUR: 300
+ CINNABAR: 300
+ STONE: 250
+ DEEPSLATE: 150
+ COBBLESTONE: 100
+ TUFF: 80
+ ANDESITE: 80
+ GRANITE: 60
+ DIORITE: 60
+ GRAVEL: 60
+ SULFUR_SPIKE: 30
+ POTENT_SULFUR: 20
+ MAGMA_BLOCK: 20
+ OBSIDIAN: 15
+ AMETHYST_BLOCK: 10
+ COAL_ORE: 40
+ IRON_ORE: 40
+ COPPER_ORE: 30
+ REDSTONE_ORE: 25
+ GOLD_ORE: 20
+ LAPIS_ORE: 15
+ DIAMOND_ORE: 8
+ EMERALD_ORE: 5
+ CHEST: 40
+ mobs:
+ SULFUR_CUBE: 100
+ CREEPER: 50
+ SKELETON: 50
+ ZOMBIE: 50
+ SLIME: 25
+ CAVE_SPIDER: 20
+ ENDERMAN: 10
+ BAT: 10
+ WITCH: 5
+ ZOMBIE_VILLAGER: 5
+ holograms:
+ '0': '&eBeware the noxious fumes!'
+ start-commands: []
+ end-commands: []
diff --git a/src/main/resources/phases/15000_sulfur_caves_chests.yml b/src/main/resources/phases/15000_sulfur_caves_chests.yml
new file mode 100644
index 0000000..a0e5dec
--- /dev/null
+++ b/src/main/resources/phases/15000_sulfur_caves_chests.yml
@@ -0,0 +1,110 @@
+# Chests for the Sulfur Caves phase. Contains 26.2-only items, so this file
+# carries the same required version tag as the main phase file.
+'15000':
+ requiredMinecraftVersion: '26.2'
+ chests:
+ '1':
+ contents:
+ 4:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:sulfur
+ count: 2
+ schema_version: 1
+ 12:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:gunpowder
+ count: 4
+ schema_version: 1
+ 13:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:bread
+ count: 4
+ schema_version: 1
+ 14:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:torch
+ count: 8
+ schema_version: 1
+ 22:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:cinnabar
+ count: 2
+ schema_version: 1
+ rarity: COMMON
+ '2':
+ contents:
+ 3:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:sulfur
+ count: 4
+ schema_version: 1
+ 5:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:cinnabar
+ count: 4
+ schema_version: 1
+ 11:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:gunpowder
+ count: 8
+ schema_version: 1
+ 13:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:iron_ingot
+ count: 4
+ schema_version: 1
+ 15:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:redstone
+ count: 8
+ schema_version: 1
+ 21:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:sulfur_spike
+ count: 2
+ schema_version: 1
+ rarity: UNCOMMON
+ '3':
+ contents:
+ 4:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:tnt
+ count: 2
+ schema_version: 1
+ 11:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:potent_sulfur
+ count: 1
+ schema_version: 1
+ 13:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:music_disc_bounce
+ count: 1
+ schema_version: 1
+ 15:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:gold_ingot
+ count: 4
+ schema_version: 1
+ 22:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:diamond
+ count: 2
+ schema_version: 1
+ rarity: RARE
diff --git a/src/main/resources/phases/15000_goto_0.yml b/src/main/resources/phases/15500_goto_0.yml
similarity index 60%
rename from src/main/resources/phases/15000_goto_0.yml
rename to src/main/resources/phases/15500_goto_0.yml
index 4910d37..0ddc53f 100644
--- a/src/main/resources/phases/15000_goto_0.yml
+++ b/src/main/resources/phases/15500_goto_0.yml
@@ -1,2 +1,2 @@
-'15000':
+'15500':
gotoBlock: 0
\ No newline at end of file
diff --git a/src/main/resources/phases/7500_the nether.yml b/src/main/resources/phases/7500_the nether.yml
index b5b6b48..794f8b5 100644
--- a/src/main/resources/phases/7500_the nether.yml
+++ b/src/main/resources/phases/7500_the nether.yml
@@ -26,7 +26,9 @@
GLOWSTONE: 100
MAGMA_BLOCK: 75
GOLD_BLOCK: 10
- DRIED_GHAST: 25
+ DRIED_GHAST:
+ weight: 25
+ requiredMinecraftVersion: '1.21.6'
mobs:
PIGLIN: 300
HOGLIN: 100
diff --git a/src/main/resources/phases_index.yml b/src/main/resources/phases_index.yml
new file mode 100644
index 0000000..27d32d8
--- /dev/null
+++ b/src/main/resources/phases_index.yml
@@ -0,0 +1,102 @@
+# Phase index for AOneBlock.
+#
+# This file is the source of truth for which phases load, in what order, and
+# how long each one is. It is read BEFORE any phase file is parsed, so phases
+# that need a newer Minecraft version are skipped without their files (and any
+# serialized items inside them) ever being touched.
+#
+# Each entry:
+# file: base name of the phase file in the phases folder (without .yml).
+# The chest file is _chests.yml.
+# section: the top-level key inside the phase file (legacy start block).
+# name: display name, used in logs and admin tools.
+# length: number of blocks in the phase. Start blocks are computed by
+# adding up the lengths of the enabled phases above, starting at 0.
+# enabled: optional, defaults to true. Set false to leave a phase out.
+# requiredMinecraftVersion: optional. The phase is skipped (taking up no
+# blocks) when the server is older than this version.
+#
+# After the last phase, the block count jumps to gotoAtEnd.
+phases:
+ - file: 0_plains
+ section: '0'
+ name: Plains
+ length: 700
+ - file: 700_underground
+ section: '700'
+ name: Underground
+ length: 1300
+ - file: 2000_winter
+ section: '2000'
+ name: Winter
+ length: 1000
+ - file: 3000_ocean
+ section: '3000'
+ name: Ocean
+ length: 1000
+ - file: 4000_jungle
+ section: '4000'
+ name: Jungle
+ length: 1000
+ - file: 5000_swamp
+ section: '5000'
+ name: Swamp
+ length: 1000
+ - file: 6000_dungeon
+ section: '6000'
+ name: Dungeon
+ length: 1000
+ - file: 7000_desert
+ section: '7000'
+ name: Desert
+ length: 500
+ - file: '7500_the nether'
+ section: '7500'
+ name: The Nether
+ length: 1000
+ - file: 8500_plenty
+ section: '8500'
+ name: Plenty
+ length: 1000
+ - file: 9500_desolation
+ section: '9500'
+ name: Desolation
+ length: 1000
+ - file: 10500_deep_dark
+ section: '10500'
+ name: Deep Dark
+ length: 1000
+ - file: '11500_the end'
+ section: '11500'
+ name: The End
+ length: 500
+ - file: 12000_lush_caves
+ section: '12000'
+ name: Lush Caves
+ length: 500
+ - file: 12500_dripstone_caves
+ section: '12500'
+ name: Dripstone Caves
+ length: 500
+ - file: 13000_mangrove_swamp
+ section: '13000'
+ name: Mangrove Swamp
+ length: 500
+ - file: 13500_meadow
+ section: '13500'
+ name: Meadow
+ length: 500
+ - file: 14000_cherry_grove
+ section: '14000'
+ name: Cherry Grove
+ length: 500
+ - file: 14500_jagged_peaks
+ section: '14500'
+ name: Jagged Peaks
+ length: 500
+ - file: 15000_sulfur_caves
+ section: '15000'
+ name: Sulfur Caves
+ length: 500
+ requiredMinecraftVersion: '26.2'
+gotoAtEnd: 0
diff --git a/src/test/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommandTest.java b/src/test/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommandTest.java
new file mode 100644
index 0000000..f39afa7
--- /dev/null
+++ b/src/test/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommandTest.java
@@ -0,0 +1,110 @@
+package world.bentobox.aoneblock.commands.admin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.TreeMap;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import world.bentobox.aoneblock.AOneBlock;
+import world.bentobox.aoneblock.CommonTestSetup;
+import world.bentobox.aoneblock.oneblocks.OneBlocksManager;
+import world.bentobox.aoneblock.oneblocks.PhaseIndexEntry;
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Tests for {@link AdminPhasesCommand}
+ */
+class AdminPhasesCommandTest extends CommonTestSetup {
+
+ @Mock
+ private CompositeCommand ac;
+ @Mock
+ private User user;
+ @Mock
+ private AOneBlock addon;
+ @Mock
+ private OneBlocksManager oneBlocksManager;
+
+ private AdminPhasesCommand cmd;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+ when(addon.getOneBlockManager()).thenReturn(oneBlocksManager);
+ cmd = new AdminPhasesCommand(ac);
+ // Inject the addon mock so canExecute/execute can call addon.getOneBlockManager()
+ java.lang.reflect.Field addonField = AdminPhasesCommand.class.getDeclaredField("addon");
+ addonField.setAccessible(true);
+ addonField.set(cmd, addon);
+ when(user.isPlayer()).thenReturn(true);
+ when(user.getPlayer()).thenReturn(mockPlayer);
+ when(user.getTranslation(anyString())).thenAnswer(i -> i.getArgument(0, String.class));
+ when(user.getTranslation(anyString(), any(String[].class))).thenAnswer(i -> i.getArgument(0, String.class));
+ }
+
+ /**
+ * Test that the command is created successfully with the right settings.
+ */
+ @Test
+ void testSetup() {
+ assertNotNull(cmd);
+ assertEquals("admin.phases", cmd.getPermission());
+ assertEquals("aoneblock.commands.admin.phases.description", cmd.getDescription());
+ assertTrue(cmd.isOnlyPlayer());
+ }
+
+ /**
+ * Test canExecute with args shows help and returns false.
+ */
+ @Test
+ void testCanExecuteWithArgs() {
+ assertFalse(cmd.canExecute(user, "phases", List.of("something")));
+ }
+
+ /**
+ * Test canExecute when no index is loaded sends a message and returns false.
+ */
+ @Test
+ void testCanExecuteNoIndex() {
+ when(oneBlocksManager.getPhaseIndex()).thenReturn(List.of());
+ assertFalse(cmd.canExecute(user, "phases", List.of()));
+ verify(user).sendMessage("aoneblock.commands.admin.phases.no-index");
+ }
+
+ /**
+ * Test canExecute with an index returns true.
+ */
+ @Test
+ void testCanExecuteWithIndex() {
+ when(oneBlocksManager.getPhaseIndex()).thenReturn(List.of(new PhaseIndexEntry()));
+ assertTrue(cmd.canExecute(user, "phases", List.of()));
+ }
+
+ /**
+ * Test execute opens the panel.
+ */
+ @Test
+ void testExecute() {
+ PhaseIndexEntry entry = new PhaseIndexEntry();
+ entry.setFile("alpha");
+ entry.setName("Alpha");
+ entry.setLength(100);
+ when(oneBlocksManager.getPhaseIndex()).thenReturn(new java.util.ArrayList<>(List.of(entry)));
+ when(oneBlocksManager.isPhaseAvailable(any())).thenReturn(true);
+ when(oneBlocksManager.getBlockProbs()).thenReturn(new TreeMap<>());
+ assertTrue(cmd.execute(user, "phases", List.of()));
+ }
+}
diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java
index f2c487e..50ab47a 100644
--- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java
+++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java
@@ -159,6 +159,7 @@ public void setUp() throws Exception {
public void tearDown() throws Exception {
super.tearDown();
deleteAll(new File("database"));
+ cleanPhaseFiles();
}
@AfterAll
@@ -547,6 +548,365 @@ void testLoadPhases_fixedBlockChestWithItem() throws Exception {
verify(plugin, never()).logError(anyString());
}
+ /**
+ * Test method for
+ * {@link world.bentobox.aoneblock.oneblocks.OneBlocksManager#isVersionAtLeast(String, String)}.
+ */
+ @Test
+ void testIsVersionAtLeast() {
+ assertTrue(OneBlocksManager.isVersionAtLeast("26.2", "26.2"));
+ assertTrue(OneBlocksManager.isVersionAtLeast("26.2.1", "26.2"));
+ assertTrue(OneBlocksManager.isVersionAtLeast("26.3", "26.2"));
+ assertTrue(OneBlocksManager.isVersionAtLeast("1.21.11", "1.21.2"));
+ assertTrue(OneBlocksManager.isVersionAtLeast("1.21.11-R0.1-SNAPSHOT", "1.21.11"));
+ assertFalse(OneBlocksManager.isVersionAtLeast("1.21.11", "26.2"));
+ assertFalse(OneBlocksManager.isVersionAtLeast("1.21", "1.21.11"));
+ assertFalse(OneBlocksManager.isVersionAtLeast("", "26.2"));
+ assertFalse(OneBlocksManager.isVersionAtLeast(null, "26.2"));
+ assertFalse(OneBlocksManager.isVersionAtLeast("1.21.11", "not-a-version"));
+ }
+
+ private static final File PHASES_DIR = new File("addons/AOneBlock/phases");
+ private static final File INDEX_FILE = new File("addons/AOneBlock/phases_index.yml");
+
+ /**
+ * Removes the phases folder and index so each index test starts clean and
+ * leaves nothing behind for the other tests.
+ */
+ private void cleanPhaseFiles() throws IOException {
+ deleteAll(PHASES_DIR);
+ java.nio.file.Files.deleteIfExists(INDEX_FILE.toPath());
+ }
+
+ /**
+ * A phase tagged with a newer Minecraft version than the server runs must be
+ * skipped with a plain log entry and no errors - and its files must never be
+ * parsed. The chest file here is deliberately invalid YAML: if the loader
+ * touched it, an error would be logged. The test server is mocked as 1.21.10
+ * in {@link CommonTestSetup}.
+ */
+ @Test
+ void testLoadPhasesSkipsPhaseRequiringNewerVersion() throws IOException {
+ PHASES_DIR.mkdirs();
+ String yaml = """
+ '15000':
+ name: Sulfur Caves
+ requiredMinecraftVersion: '26.2'
+ firstBlock: SULFUR
+ biome: SULFUR_CAVES
+ blocks:
+ SULFUR: 100
+ mobs:
+ SULFUR_CUBE: 100
+ """;
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "15000_sulfur_caves.yml").toPath(), yaml);
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "15000_sulfur_caves_chests.yml").toPath(),
+ "{{{{ this is not YAML :::");
+ obm.loadPhases();
+ assertTrue(obm.getBlockProbs().isEmpty());
+ assertTrue(INDEX_FILE.exists(), "Index should have been generated from the phase files");
+ verify(plugin).log(org.mockito.ArgumentMatchers.contains("Skipping phase Sulfur Caves"));
+ verify(plugin, never()).logError(anyString());
+ verify(plugin, never()).logWarning(anyString());
+ }
+
+ /**
+ * A phase whose required version is satisfied by the server must load normally
+ * and keep the version tag. With the index, start blocks are the running sum
+ * of lengths, so a single phase starts at 0 regardless of its legacy key.
+ */
+ @Test
+ void testLoadPhasesLoadsPhaseWithSatisfiedVersion() throws IOException {
+ PHASES_DIR.mkdirs();
+ String yaml = """
+ '15000':
+ name: Sulfur Caves
+ requiredMinecraftVersion: '1.21'
+ firstBlock: STONE
+ biome: PLAINS
+ blocks:
+ STONE: 100
+ mobs:
+ CAVE_SPIDER: 20
+ """;
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "15000_sulfur_caves.yml").toPath(), yaml);
+ obm.loadPhases();
+ assertTrue(obm.getBlockProbs().containsKey(0));
+ OneBlockPhase phase = obm.getPhase(0);
+ assertEquals("Sulfur Caves", phase.getPhaseName());
+ assertEquals(Material.STONE, phase.getFirstBlock().getMaterial());
+ assertEquals("1.21", phase.getRequiredMinecraftVersion());
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * A disabled phase takes up no blocks: the next phase shifts down to fill the
+ * gap and the goto lands right after the last loaded phase.
+ */
+ @Test
+ void testLoadPhasesDisabledPhaseCollapses() throws IOException {
+ PHASES_DIR.mkdirs();
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha.yml").toPath(), """
+ '0':
+ name: Alpha
+ biome: PLAINS
+ blocks:
+ GRASS_BLOCK: 100
+ """);
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "beta.yml").toPath(), """
+ '100':
+ name: Beta
+ biome: PLAINS
+ blocks:
+ STONE: 100
+ """);
+ java.nio.file.Files.writeString(INDEX_FILE.toPath(), """
+ phases:
+ - file: alpha
+ section: '0'
+ name: Alpha
+ length: 100
+ enabled: false
+ - file: beta
+ section: '100'
+ name: Beta
+ length: 200
+ gotoAtEnd: 0
+ """);
+ obm.loadPhases();
+ assertEquals("Beta", obm.getPhase(0).getPhaseName());
+ assertEquals(0, (int) obm.getPhase(200).getGotoBlock());
+ assertEquals(2, obm.getBlockProbs().size());
+ verify(plugin).log(org.mockito.ArgumentMatchers.contains("Skipping phase Alpha"));
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * Chest files are read without eager deserialization: an item that does not
+ * exist on this server version is skipped with a log line, the rest of the
+ * chest loads, and no errors are thrown.
+ */
+ @Test
+ void testLoadPhasesChestWithUnknownItemSkipsItem() throws IOException {
+ PHASES_DIR.mkdirs();
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha.yml").toPath(), """
+ '0':
+ name: Alpha
+ biome: PLAINS
+ blocks:
+ GRASS_BLOCK: 100
+ """);
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha_chests.yml").toPath(), """
+ '0':
+ chests:
+ '1':
+ contents:
+ 0:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:sulfur
+ count: 1
+ schema_version: 1
+ 1:
+ ==: org.bukkit.inventory.ItemStack
+ DataVersion: 4438
+ id: minecraft:iron_ingot
+ count: 4
+ schema_version: 1
+ rarity: COMMON
+ """);
+ java.nio.file.Files.writeString(INDEX_FILE.toPath(), """
+ phases:
+ - file: alpha
+ section: '0'
+ name: Alpha
+ length: 100
+ """);
+ obm.loadPhases();
+ OneBlockPhase phase = obm.getPhase(0);
+ assertNotNull(phase);
+ assertEquals(1, phase.getChests().size());
+ assertEquals(1, phase.getChests().iterator().next().getChest().size());
+ verify(plugin).log(org.mockito.ArgumentMatchers.contains("Skipping item minecraft:sulfur"));
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * Object-form block entries can carry a required Minecraft version; gated
+ * entries are skipped with a log line, satisfied ones load with their weight.
+ * The test server is mocked as 1.21.10.
+ */
+ @Test
+ void testAddBlocksVersionedEntries() throws InvalidConfigurationException {
+ String yaml = """
+ blocks:
+ STONE: 100
+ GOLD_BLOCK:
+ weight: 50
+ requiredMinecraftVersion: '26.2'
+ IRON_BLOCK:
+ weight: 25
+ requiredMinecraftVersion: '1.20'
+ """;
+ YamlConfiguration cfg = new YamlConfiguration();
+ cfg.loadFromString(yaml);
+ obm.addBlocks(obPhase, cfg);
+ assertTrue(obPhase.getBlocks().containsKey(Material.STONE));
+ assertFalse(obPhase.getBlocks().containsKey(Material.GOLD_BLOCK));
+ assertEquals(25, obPhase.getBlocks().get(Material.IRON_BLOCK));
+ verify(plugin).log(org.mockito.ArgumentMatchers.contains("Skipping block GOLD_BLOCK"));
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * Object-form mob entries can carry a required Minecraft version, matching the
+ * block behaviour.
+ */
+ @Test
+ void testAddMobsVersionedEntries() throws InvalidConfigurationException, IOException {
+ String yaml = """
+ mobs:
+ ZOMBIE: 20
+ WITHER_SKELETON:
+ weight: 10
+ requiredMinecraftVersion: '26.2'
+ CAVE_SPIDER:
+ weight: 5
+ requiredMinecraftVersion: '1.19'
+ """;
+ YamlConfiguration cfg = new YamlConfiguration();
+ cfg.loadFromString(yaml);
+ obm.addMobs(obPhase, cfg);
+ assertEquals(20, obPhase.getMobs().get(org.bukkit.entity.EntityType.ZOMBIE));
+ assertFalse(obPhase.getMobs().containsKey(org.bukkit.entity.EntityType.WITHER_SKELETON));
+ assertEquals(5, obPhase.getMobs().get(org.bukkit.entity.EntityType.CAVE_SPIDER));
+ verify(plugin).log(org.mockito.ArgumentMatchers.contains("Skipping mob WITHER_SKELETON"));
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * After loading, the manager holds the live index model: every entry in phase
+ * order with its length, plus the goto target.
+ */
+ @Test
+ void testGetPhaseIndex() throws IOException {
+ obm.loadPhases();
+ List index = obm.getPhaseIndex();
+ assertEquals(2, index.size());
+ assertEquals("Plains", index.get(0).getName());
+ assertEquals(700, index.get(0).getLength());
+ assertEquals("Underground", index.get(1).getName());
+ assertEquals(10300, index.get(1).getLength());
+ assertTrue(index.get(0).isEnabled());
+ assertEquals(0, (int) obm.getGotoAtEnd());
+ // Loaded phases know their index entry
+ assertEquals(index.get(0), obm.getPhase(0).getIndexEntry());
+ }
+
+ /**
+ * Reordering the live index, saving it, and reloading moves the phases: start
+ * blocks are recomputed from the new order's lengths.
+ */
+ @Test
+ void testReorderPhaseIndex() throws IOException {
+ cleanPhaseFiles();
+ PHASES_DIR.mkdirs();
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha.yml").toPath(), """
+ '0':
+ name: Alpha
+ biome: PLAINS
+ blocks:
+ GRASS_BLOCK: 100
+ """);
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "beta.yml").toPath(), """
+ '100':
+ name: Beta
+ biome: PLAINS
+ blocks:
+ STONE: 100
+ """);
+ java.nio.file.Files.writeString(INDEX_FILE.toPath(), """
+ phases:
+ - file: alpha
+ section: '0'
+ name: Alpha
+ length: 100
+ - file: beta
+ section: '100'
+ name: Beta
+ length: 200
+ gotoAtEnd: 0
+ """);
+ obm.loadPhases();
+ assertEquals("Alpha", obm.getPhase(0).getPhaseName());
+ assertEquals("Beta", obm.getPhase(100).getPhaseName());
+ // Swap the two phases and apply
+ java.util.Collections.swap(obm.getPhaseIndex(), 0, 1);
+ assertTrue(obm.saveIndex());
+ obm.loadPhases();
+ assertEquals("Beta", obm.getPhase(0).getPhaseName());
+ assertEquals("Alpha", obm.getPhase(200).getPhaseName());
+ assertEquals(0, (int) obm.getPhase(300).getGotoBlock());
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * Saving an indexed phase writes back to the file it came from, under its
+ * stable section key - not to a file named after the computed start block.
+ */
+ @Test
+ void testSavePhaseKeepsIndexedFileName() throws IOException, InvalidConfigurationException {
+ cleanPhaseFiles();
+ PHASES_DIR.mkdirs();
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha.yml").toPath(), """
+ '5000':
+ name: Alpha
+ biome: PLAINS
+ blocks:
+ GRASS_BLOCK: 100
+ """);
+ java.nio.file.Files.writeString(INDEX_FILE.toPath(), """
+ phases:
+ - file: alpha
+ section: '5000'
+ name: Alpha
+ length: 100
+ """);
+ obm.loadPhases();
+ OneBlockPhase phase = obm.getPhase(0);
+ assertNotNull(phase);
+ assertTrue(obm.savePhase(phase));
+ // Same file, same section key, no new file named after the start block
+ assertTrue(new File(PHASES_DIR, "alpha.yml").exists());
+ assertFalse(new File(PHASES_DIR, "0_alpha.yml").exists());
+ YamlConfiguration saved = new YamlConfiguration();
+ saved.load(new File(PHASES_DIR, "alpha.yml"));
+ assertTrue(saved.isConfigurationSection("5000"));
+ assertEquals("Alpha", saved.getString("5000.name"));
+ verify(plugin, never()).logError(anyString());
+ }
+
+ /**
+ * A malformed index must not stop the addon: it logs an error and falls back
+ * to loading the phase files directly, as before the index existed.
+ */
+ @Test
+ void testLoadPhasesMalformedIndexFallsBack() throws IOException {
+ PHASES_DIR.mkdirs();
+ java.nio.file.Files.writeString(new File(PHASES_DIR, "alpha.yml").toPath(), """
+ '0':
+ name: Alpha
+ biome: PLAINS
+ blocks:
+ GRASS_BLOCK: 100
+ """);
+ java.nio.file.Files.writeString(INDEX_FILE.toPath(), "{{{{ this is not YAML :::");
+ obm.loadPhases();
+ assertEquals("Alpha", obm.getPhase(0).getPhaseName());
+ verify(plugin).logError(org.mockito.ArgumentMatchers.contains("Could not load phases_index.yml"));
+ verify(plugin).logWarning(org.mockito.ArgumentMatchers.contains("Phase index could not be used"));
+ }
+
/**
* Test that an invalid {@code CHEST_WITH_X} entry (unknown item) logs an error
* and is ignored — it must not appear in fixedBlocks.
diff --git a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java
new file mode 100644
index 0000000..52226df
--- /dev/null
+++ b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java
@@ -0,0 +1,168 @@
+package world.bentobox.aoneblock.panels;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import world.bentobox.aoneblock.AOneBlock;
+import world.bentobox.aoneblock.CommonTestSetup;
+import world.bentobox.aoneblock.oneblocks.OneBlocksManager;
+import world.bentobox.aoneblock.oneblocks.PhaseIndexEntry;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Tests for {@link AdminPhasesPanel} - the pick-up / drop / toggle logic.
+ */
+class AdminPhasesPanelTest extends CommonTestSetup {
+
+ @Mock
+ private AOneBlock addon;
+ @Mock
+ private OneBlocksManager obm;
+ @Mock
+ private User user;
+
+ private List index;
+ private AdminPhasesPanel panel;
+
+ private PhaseIndexEntry entry(String name) {
+ PhaseIndexEntry e = new PhaseIndexEntry();
+ e.setFile(name.toLowerCase());
+ e.setSection("0");
+ e.setName(name);
+ e.setLength(100);
+ return e;
+ }
+
+ private List names() {
+ return index.stream().map(PhaseIndexEntry::getName).toList();
+ }
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+ when(addon.getOneBlockManager()).thenReturn(obm);
+ index = new ArrayList<>(List.of(entry("Alpha"), entry("Beta"), entry("Gamma")));
+ when(obm.getPhaseIndex()).thenReturn(index);
+ when(obm.isPhaseAvailable(any())).thenReturn(true);
+ when(obm.getBlockProbs()).thenReturn(new TreeMap<>());
+ when(obm.getGotoAtEnd()).thenReturn(0);
+ when(obm.saveIndex()).thenReturn(true);
+ when(user.getTranslation(anyString())).thenAnswer(i -> i.getArgument(0, String.class));
+ when(user.getTranslation(anyString(), any(String[].class))).thenAnswer(i -> i.getArgument(0, String.class));
+ when(user.getPlayer()).thenReturn(mockPlayer);
+ panel = new AdminPhasesPanel(addon, user);
+ }
+
+ /**
+ * The panel builds without errors in both idle and holding states.
+ */
+ @Test
+ void testBuild() {
+ panel.build();
+ panel.pickUp(1);
+ panel.build();
+ verify(addon, never()).logError(anyString());
+ }
+
+ /**
+ * Picking up the first phase and dropping it on the second display position
+ * shoves the others right: Alpha moves after Beta.
+ */
+ @Test
+ void testPickUpAndDrop() throws IOException {
+ panel.pickUp(0);
+ panel.dropAt(1);
+ assertEquals(List.of("Beta", "Alpha", "Gamma"), names());
+ verify(obm).saveIndex();
+ verify(obm).loadPhases();
+ verify(user).sendMessage("aoneblock.commands.admin.phases.saved");
+ }
+
+ /**
+ * Dropping at the end position appends the held phase.
+ */
+ @Test
+ void testDropAtEnd() throws IOException {
+ panel.pickUp(0);
+ panel.dropAt(2);
+ assertEquals(List.of("Beta", "Gamma", "Alpha"), names());
+ verify(obm).saveIndex();
+ }
+
+ /**
+ * Dropping a phase back where it came from is a no-op order-wise but still
+ * persists cleanly.
+ */
+ @Test
+ void testDropAtSamePlace() {
+ panel.pickUp(1);
+ panel.dropAt(1);
+ assertEquals(List.of("Alpha", "Beta", "Gamma"), names());
+ }
+
+ /**
+ * Putting a held phase back changes nothing and saves nothing.
+ */
+ @Test
+ void testPutBack() throws IOException {
+ panel.pickUp(2);
+ panel.putBack();
+ assertEquals(List.of("Alpha", "Beta", "Gamma"), names());
+ verify(obm, never()).saveIndex();
+ verify(obm, never()).loadPhases();
+ }
+
+ /**
+ * Right-click toggling flips the enabled state and persists.
+ */
+ @Test
+ void testToggle() throws IOException {
+ PhaseIndexEntry beta = index.get(1);
+ assertTrue(beta.isEnabled());
+ panel.toggle(beta);
+ assertFalse(beta.isEnabled());
+ verify(obm).saveIndex();
+ verify(obm).loadPhases();
+ panel.toggle(beta);
+ assertTrue(beta.isEnabled());
+ }
+
+ /**
+ * A failed save tells the admin instead of failing silently.
+ */
+ @Test
+ void testPersistFailure() {
+ when(obm.saveIndex()).thenReturn(false);
+ panel.pickUp(0);
+ panel.dropAt(2);
+ verify(user).sendMessage("aoneblock.commands.admin.phases.save-failed");
+ }
+
+ /**
+ * A reload failure after saving is reported too.
+ */
+ @Test
+ void testReloadFailure() throws IOException {
+ org.mockito.Mockito.doThrow(new IOException("boom")).when(obm).loadPhases();
+ panel.pickUp(0);
+ panel.dropAt(2);
+ verify(addon).logError("Could not reload phases: boom");
+ verify(user).sendMessage("aoneblock.commands.admin.phases.save-failed");
+ }
+}