From 5a987cf5ef3e97fd02067cd804827a83c22c677a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 17:25:35 -0700 Subject: [PATCH 1/4] feat: add Sulfur Caves phase (MC 26.2) and version-gated phase index Adds a Sulfur Caves phase at the 15000 spot with wiki-accurate mob spawn weights (Sulfur Cube 100, Creeper/Skeleton/Zombie 50 each, Slime 25, Cave Spider 20, Enderman 10, Witch/Zombie Villager 5, ambient Bat 10), sulfur and cinnabar blocks mixed with typical underground blocks, and themed chests. The goto loop moves from 15000 to 15500. Because the biome, blocks, mobs, and chest items only exist from Minecraft 26.2, phases can now declare requiredMinecraftVersion and are skipped with an info log on older servers: - New phases_index.yml is read before any phase file, so gated phases are skipped without their files ever being parsed. This matters because YamlConfiguration eagerly deserializes ItemStacks during parsing, which threw errors for unknown items. The index also carries per-phase order, length, and enabled state (start blocks are the running sum of lengths), preparing for admin phase reordering. Legacy installs get an index generated automatically; a malformed index falls back to direct file loading. - Chest files are now read with plain SnakeYAML and each item is built individually, so an item unknown to the server version is skipped with a log line instead of a stack trace. - Block and mob entries accept an object form with a weight and requiredMinecraftVersion, used for DRIED_GHAST (1.21.6) in The Nether phase. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VahJUVuDRo5kRSDZKJHHjC --- .../aoneblock/oneblocks/OneBlockPhase.java | 17 + .../aoneblock/oneblocks/OneBlocksManager.java | 501 +++++++++++++++++- .../resources/phases/15000_sulfur_caves.yml | 55 ++ .../phases/15000_sulfur_caves_chests.yml | 110 ++++ .../{15000_goto_0.yml => 15500_goto_0.yml} | 2 +- src/main/resources/phases/7500_the nether.yml | 4 +- src/main/resources/phases_index.yml | 102 ++++ .../oneblocks/OneBlocksManagerTest3.java | 258 +++++++++ 8 files changed, 1025 insertions(+), 24 deletions(-) create mode 100644 src/main/resources/phases/15000_sulfur_caves.yml create mode 100644 src/main/resources/phases/15000_sulfur_caves_chests.yml rename src/main/resources/phases/{15000_goto_0.yml => 15500_goto_0.yml} (60%) create mode 100644 src/main/resources/phases_index.yml diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java index 2f56fdd..ed2ed0f 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java @@ -50,6 +50,7 @@ public class OneBlockPhase { private final Random chestRandom; private final String blockNumber; private Integer gotoBlock; + private String requiredMinecraftVersion; private int blockTotal = 0; private int entityTotal = 0; private List startCommands; @@ -340,6 +341,22 @@ public void setGotoBlock(Integer gotoBlock) { this.gotoBlock = gotoBlock; } + /** + * @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..f199e0b 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,6 +82,20 @@ 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 INDEX_FILE = "file"; + private static final String INDEX_SECTION = "section"; + private static final String INDEX_LENGTH = "length"; + private static final String INDEX_ENABLED = "enabled"; + private static final String GOTO_AT_END = "gotoAtEnd"; + private static final String CHESTS_YML_SUFFIX = "_chests.yml"; + private static final String WEIGHT = "weight"; + private 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?"; @@ -115,14 +139,378 @@ 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; + Map entry = scan.get(start); + entry.put(INDEX_LENGTH, next > start ? next - start : DEFAULT_PHASE_LENGTH); + entries.add(entry); + } + YamlConfiguration index = new YamlConfiguration(); + index.set(INDEX_PHASES, entries); + if (gotoTarget != null) { + index.set(GOTO_AT_END, gotoTarget); + } + try { + index.save(indexFile); + addon.log("Created " + PHASES_INDEX_YML + " from the existing phase files."); + } catch (IOException e) { + addon.logError("Could not save " + PHASES_INDEX_YML + " " + e.getMessage()); + } + } + + /** + * 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; + } + Map entry = new LinkedHashMap<>(); + entry.put(INDEX_FILE, base); + entry.put(INDEX_SECTION, key); + entry.put(NAME, section.getString(NAME, base)); + String requiredVersion = Objects.toString(section.get(REQUIRED_MC_VERSION), ""); + if (!requiredVersion.isEmpty()) { + entry.put(REQUIRED_MC_VERSION, requiredVersion); + } + scan.put(Integer.parseInt(key), entry); + } + } + + /** + * 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 = index.getMapList(INDEX_PHASES); + if (entries.isEmpty()) { + addon.logError(PHASES_INDEX_YML + " contains no phases."); + return false; + } + int startBlock = 0; + for (Map entry : entries) { + startBlock = loadIndexedPhase(phaseFolder, entry, startBlock); + } + if (index.contains(GOTO_AT_END)) { + OneBlockPhase gotoPhase = new OneBlockPhase(String.valueOf(startBlock)); + gotoPhase.setGotoBlock(index.getInt(GOTO_AT_END, 0)); + 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, Map entry, int startBlock) { + String fileBase = Objects.toString(entry.get(INDEX_FILE), null); + if (fileBase == null) { + addon.logError(PHASES_INDEX_YML + " entry is missing the file name. Skipping it."); + return startBlock; + } + String name = Objects.toString(entry.get(NAME), fileBase); + if (Boolean.FALSE.equals(entry.get(INDEX_ENABLED))) { + addon.log("Skipping phase " + name + ": disabled in " + PHASES_INDEX_YML + "."); + return startBlock; + } + String requiredVersion = Objects.toString(entry.get(REQUIRED_MC_VERSION), ""); + 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, fileBase + ".yml"); + if (!mainFile.exists()) { + addon.logError(PHASES_INDEX_YML + ": " + mainFile.getName() + " does not exist. Skipping phase " + name + + "."); + return startBlock; + } + String section = Objects.toString(entry.get(INDEX_SECTION), null); + String blockNumber = String.valueOf(startBlock); + OneBlockPhase obPhase = new OneBlockPhase(blockNumber); + if (!requiredVersion.isEmpty()) { + obPhase.setRequiredMinecraftVersion(requiredVersion); + } + try { + addon.log("Loading " + mainFile.getName()); + ConfigurationSection phaseConfig = getPhaseSection(mainFile, section); + 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, fileBase, section, obPhase, name); + blockProbs.put(startBlock, obPhase); + return startBlock + getIndexedLength(entry, name); + } + + /** + * 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 getIndexedLength(Map entry, String name) { + if (entry.get(INDEX_LENGTH) instanceof Number number && number.intValue() > 0) { + return number.intValue(); + } + addon.logWarning("Phase " + name + " 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 +537,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 +891,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 +910,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 +943,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 +960,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)) { @@ -667,6 +1118,9 @@ public boolean savePhase(OneBlockPhase p) { private boolean saveMainPhase(OneBlockPhase p) { YamlConfiguration oneBlocks = new YamlConfiguration(); ConfigurationSection phSec = oneBlocks.createSection(p.getBlockNumber()); + 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()); @@ -713,6 +1167,9 @@ private void saveHolos(ConfigurationSection phSec, OneBlockPhase p) { private boolean saveChestPhase(OneBlockPhase p) { YamlConfiguration oneBlocks = new YamlConfiguration(); ConfigurationSection phSec = oneBlocks.createSection(p.getBlockNumber()); + if (p.getRequiredMinecraftVersion() != null) { + phSec.set(REQUIRED_MC_VERSION, p.getRequiredMinecraftVersion()); + } saveChests(phSec, p); try { // Save 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/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index f2c487e..9d45604 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,263 @@ 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()); + } + + /** + * 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. From 4f7a319d61f8bc9d1524b664cd66229151a1c6ae Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 17:29:57 -0700 Subject: [PATCH 2/4] feat: length-based phase model with live, editable phase index Turns the phase index into a first-class in-memory model to prepare for admin phase reordering: - New PhaseIndexEntry holds file, section, name, length, enabled, and requiredMinecraftVersion. OneBlocksManager keeps the live list (including disabled/version-skipped entries) plus gotoAtEnd, and exposes getPhaseIndex()/saveIndex() so tools can reorder entries, save, and reload. Start blocks are recomputed from the new order's lengths on reload. - Loaded phases keep a reference to their index entry. Saving a phase now writes back to its stable file name and section key instead of deriving them from the computed start block, so /oba setchest keeps working after phases move. - saveOneBlockConfig writes the index too and no longer writes a file for the synthetic end-goto phase, which gotoAtEnd covers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VahJUVuDRo5kRSDZKJHHjC --- .../aoneblock/oneblocks/OneBlockPhase.java | 16 ++ .../aoneblock/oneblocks/OneBlocksManager.java | 177 +++++++++++++----- .../aoneblock/oneblocks/PhaseIndexEntry.java | 166 ++++++++++++++++ .../oneblocks/OneBlocksManagerTest3.java | 102 ++++++++++ 4 files changed, 413 insertions(+), 48 deletions(-) create mode 100644 src/main/java/world/bentobox/aoneblock/oneblocks/PhaseIndexEntry.java diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java index ed2ed0f..1f45e90 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockPhase.java @@ -51,6 +51,7 @@ public class OneBlockPhase { private final String blockNumber; private Integer gotoBlock; private String requiredMinecraftVersion; + private PhaseIndexEntry indexEntry; private int blockTotal = 0; private int entityTotal = 0; private List startCommands; @@ -341,6 +342,21 @@ 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 diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index f199e0b..de0c7cd 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -86,10 +86,6 @@ public class OneBlocksManager { 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 INDEX_FILE = "file"; - private static final String INDEX_SECTION = "section"; - private static final String INDEX_LENGTH = "length"; - private static final String INDEX_ENABLED = "enabled"; private static final String GOTO_AT_END = "gotoAtEnd"; private static final String CHESTS_YML_SUFFIX = "_chests.yml"; private static final String WEIGHT = "weight"; @@ -101,6 +97,8 @@ public class OneBlocksManager { 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 @@ -117,8 +115,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()) { @@ -183,7 +183,7 @@ private void copyIndexFromAddonJar() { * @param indexFile index file to write */ void generateIndex(File phaseFolder, File indexFile) { - TreeMap> scan = new TreeMap<>(); + TreeMap scan = new TreeMap<>(); Integer gotoTarget = null; int gotoStart = -1; FilenameFilter mainYmlFilter = (dir, name) -> name.toLowerCase(Locale.ENGLISH).endsWith(".yml") @@ -213,25 +213,17 @@ void generateIndex(File phaseFolder, File indexFile) { if (scan.isEmpty()) { return; } - List> entries = new ArrayList<>(); + 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; - Map entry = scan.get(start); - entry.put(INDEX_LENGTH, next > start ? next - start : DEFAULT_PHASE_LENGTH); + PhaseIndexEntry entry = scan.get(start); + entry.setLength(next > start ? next - start : DEFAULT_PHASE_LENGTH); entries.add(entry); } - YamlConfiguration index = new YamlConfiguration(); - index.set(INDEX_PHASES, entries); - if (gotoTarget != null) { - index.set(GOTO_AT_END, gotoTarget); - } - try { - index.save(indexFile); + if (writeIndex(indexFile, entries, gotoTarget)) { addon.log("Created " + PHASES_INDEX_YML + " from the existing phase files."); - } catch (IOException e) { - addon.logError("Could not save " + PHASES_INDEX_YML + " " + e.getMessage()); } } @@ -242,7 +234,7 @@ void generateIndex(File phaseFolder, File indexFile) { * @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) { + private void scanPhaseFile(YamlConfiguration cfg, String base, TreeMap scan) { for (String key : cfg.getKeys(false)) { if (!NumberUtils.isCreatable(key)) { continue; @@ -251,18 +243,79 @@ private void scanPhaseFile(YamlConfiguration cfg, String base, TreeMap entry = new LinkedHashMap<>(); - entry.put(INDEX_FILE, base); - entry.put(INDEX_SECTION, key); - entry.put(NAME, section.getString(NAME, base)); + 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.put(REQUIRED_MC_VERSION, requiredVersion); + 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; + } + + /** + * @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 @@ -280,18 +333,28 @@ boolean loadPhasesFromIndex(File indexFile, File phaseFolder) { addon.logError("Could not load " + PHASES_INDEX_YML + ": " + e.getMessage()); return false; } - List> entries = index.getMapList(INDEX_PHASES); + 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 (Map entry : entries) { + for (PhaseIndexEntry entry : entries) { startBlock = loadIndexedPhase(phaseFolder, entry, startBlock); } - if (index.contains(GOTO_AT_END)) { + if (gotoAtEnd != null) { OneBlockPhase gotoPhase = new OneBlockPhase(String.valueOf(startBlock)); - gotoPhase.setGotoBlock(index.getInt(GOTO_AT_END, 0)); + gotoPhase.setGotoBlock(gotoAtEnd); blockProbs.put(startBlock, gotoPhase); } return true; @@ -306,37 +369,32 @@ boolean loadPhasesFromIndex(File indexFile, File phaseFolder) { * @return the start block for the next phase - unchanged if this one was * skipped */ - private int loadIndexedPhase(File phaseFolder, Map entry, int startBlock) { - String fileBase = Objects.toString(entry.get(INDEX_FILE), null); - if (fileBase == null) { - addon.logError(PHASES_INDEX_YML + " entry is missing the file name. Skipping it."); - return startBlock; - } - String name = Objects.toString(entry.get(NAME), fileBase); - if (Boolean.FALSE.equals(entry.get(INDEX_ENABLED))) { + 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.get(REQUIRED_MC_VERSION), ""); + 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, fileBase + ".yml"); + 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 section = Objects.toString(entry.get(INDEX_SECTION), null); 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, section); + ConfigurationSection phaseConfig = getPhaseSection(mainFile, entry.getSection()); if (phaseConfig == null) { addon.logError(mainFile.getName() + " has no phase section. Skipping phase " + name + "."); return startBlock; @@ -346,9 +404,9 @@ private int loadIndexedPhase(File phaseFolder, Map entry, int startBlock) addon.logError("Could not load phase " + name + ": " + e.getMessage()); return startBlock; } - loadIndexedChests(phaseFolder, fileBase, section, obPhase, name); + loadIndexedChests(phaseFolder, entry.getFile(), entry.getSection(), obPhase, name); blockProbs.put(startBlock, obPhase); - return startBlock + getIndexedLength(entry, name); + return startBlock + phaseLength(entry); } /** @@ -451,11 +509,11 @@ private void addChestsFromRaw(OneBlockPhase obPhase, Map chests, String fi } } - private int getIndexedLength(Map entry, String name) { - if (entry.get(INDEX_LENGTH) instanceof Number number && number.intValue() > 0) { - return number.intValue(); + private int phaseLength(PhaseIndexEntry entry) { + if (entry.getLength() > 0) { + return entry.getLength(); } - addon.logWarning("Phase " + name + " has no valid length in " + PHASES_INDEX_YML + ". Using " + addon.logWarning("Phase " + entry.getName() + " has no valid length in " + PHASES_INDEX_YML + ". Using " + DEFAULT_PHASE_LENGTH + "."); return DEFAULT_PHASE_LENGTH; } @@ -1091,8 +1149,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; } @@ -1117,7 +1182,7 @@ 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()); } @@ -1166,7 +1231,7 @@ 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()); } @@ -1184,6 +1249,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(); } @@ -1191,6 +1261,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/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index 9d45604..50ab47a 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -784,6 +784,108 @@ void testAddMobsVersionedEntries() throws InvalidConfigurationException, IOExcep 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. From a9b71ad8a68c4ca57af7d3848088e6fd420e3442 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 17:48:20 -0700 Subject: [PATCH 3/4] feat: add /oba phases GUI to reorder, insert, and toggle phases New admin panel (permission aoneblock.admin.phases) showing every phase index entry in order with its computed start block, length, and state. Click a phase to pick it up - the rest shrink left to fill the gap. Click a phase to shove it and everything after it right and drop the held phase there, or use the drop-at-end slot. Clicking anywhere else or closing the panel puts the phase back without saving. Right-click toggles a phase on or off. Drops and toggles save the phase index and reload the phases, so the new order takes effect immediately. Disabled phases show as gray glass and version-locked phases as barriers; both can still be reordered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VahJUVuDRo5kRSDZKJHHjC --- .../commands/admin/AdminCommand.java | 2 + .../commands/admin/AdminPhasesCommand.java | 51 ++++ .../aoneblock/oneblocks/OneBlocksManager.java | 18 +- .../aoneblock/panels/AdminPhasesPanel.java | 248 ++++++++++++++++++ src/main/resources/addon.yml | 3 + src/main/resources/locales/en-US.yml | 21 ++ .../admin/AdminPhasesCommandTest.java | 110 ++++++++ .../panels/AdminPhasesPanelTest.java | 168 ++++++++++++ 8 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommand.java create mode 100644 src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java create mode 100644 src/test/java/world/bentobox/aoneblock/commands/admin/AdminPhasesCommandTest.java create mode 100644 src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java 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/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index de0c7cd..5081e6c 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -89,7 +89,10 @@ public class OneBlocksManager { private static final String GOTO_AT_END = "gotoAtEnd"; private static final String CHESTS_YML_SUFFIX = "_chests.yml"; private static final String WEIGHT = "weight"; - private static final int DEFAULT_PHASE_LENGTH = 500; + /** + * 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 "; @@ -299,6 +302,19 @@ 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 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/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/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"); + } +} From b40484c700995932412389446b7ab52ce6ee0d6e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 18:11:44 -0700 Subject: [PATCH 4/4] build: bump version to 1.26.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VahJUVuDRo5kRSDZKJHHjC --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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