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