diff --git a/pom.xml b/pom.xml index 9479e23..382fa72 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.26.0 + 1.26.1 BentoBoxWorld_AOneBlock bentobox-world diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index 5081e6c..b1bfbf5 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -10,6 +10,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -18,7 +19,10 @@ import java.util.NavigableMap; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.TreeMap; +import java.util.function.Predicate; +import java.util.stream.Stream; import java.util.jar.JarFile; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -83,10 +87,13 @@ public class OneBlocksManager { 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+)*)"); + // Possessive quantifiers - version strings need no backtracking and this + // keeps pathological inputs from recursing deeply in the regex engine + 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 ADMIN_LENGTHS = "adminLengths"; private static final String CHESTS_YML_SUFFIX = "_chests.yml"; private static final String WEIGHT = "weight"; /** @@ -102,6 +109,11 @@ public class OneBlocksManager { private TreeMap blockProbs; private List phaseIndex = new ArrayList<>(); private Integer gotoAtEnd; + /** + * True once an admin has set phase lengths through a tool. Reconciliation + * then never overwrites lengths from the files' legacy start-block keys. + */ + private boolean adminLengths; /** * @param addon - addon @@ -122,40 +134,17 @@ public void loadPhases() throws IOException { blockProbs = new TreeMap<>(); phaseIndex = new ArrayList<>(); gotoAtEnd = null; + adminLengths = false; // Check for folder File check = new File(addon.getDataFolder(), PHASES); if (check.mkdirs()) { - addon.log(check.getAbsolutePath() + " does not exist, made folder."); - // Check for oneblock.yml - File oneblockFile = new File(addon.getDataFolder(), ONE_BLOCKS_YML); - if (oneblockFile.exists()) { - // Migrate to new folders - File renamedFile = new File(check, ONE_BLOCKS_YML); - Files.move(oneblockFile, renamedFile); - loadPhase(renamedFile); - this.saveOneBlockConfig(); - java.nio.file.Files.delete(oneblockFile.toPath()); - java.nio.file.Files.delete(renamedFile.toPath()); - blockProbs.clear(); - } else { - // Copy files from JAR - copyPhasesFromAddonJar(check); - } - } - // 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(); + setUpNewFolder(check); } - if (!indexFile.exists()) { - // Legacy install - build the index from the phase files on disk - generateIndex(check, indexFile); - } - if (indexFile.exists() && loadPhasesFromIndex(indexFile, check)) { + if (loadUsingIndex(check)) { return; } // Fallback - load every phase file directly as before the index existed + gotoAtEnd = null; File[] phaseFiles = Objects.requireNonNull(check.listFiles(YML_FILTER)); if (phaseFiles.length > 0) { addon.logWarning("Phase index could not be used - loading phase files directly."); @@ -165,6 +154,71 @@ public void loadPhases() throws IOException { } } + /** + * Fills a freshly made phases folder: migrates a legacy single-file setup if + * one exists, otherwise copies the shipped phase files from the jar. + */ + private void setUpNewFolder(File check) throws IOException { + addon.log(check.getAbsolutePath() + " does not exist, made folder."); + // Check for oneblock.yml + File oneblockFile = new File(addon.getDataFolder(), ONE_BLOCKS_YML); + if (oneblockFile.exists()) { + // Migrate to new folders + File renamedFile = new File(check, ONE_BLOCKS_YML); + Files.move(oneblockFile, renamedFile); + loadPhase(renamedFile); + this.saveOneBlockConfig(); + java.nio.file.Files.delete(oneblockFile.toPath()); + java.nio.file.Files.delete(renamedFile.toPath()); + blockProbs.clear(); + } else { + // Copy files from JAR + copyPhasesFromAddonJar(check); + } + } + + /** + * Loads the phases via the phase index. The index is read before any phase + * file so that phases needing a newer server version are skipped without + * their files ever being parsed, and it is reconciled with the phases folder + * first - the folder is the source of truth for which phases exist, so + * custom or renamed phase files always load and the admin phases panel shows + * this server's reality, not a preset. + * + * @param check the phases folder + * @return true if the phases were loaded, false to fall back to loading the + * phase files directly + */ + private boolean loadUsingIndex(File check) { + File indexFile = new File(addon.getDataFolder(), PHASES_INDEX_YML); + boolean freshIndex = !indexFile.exists(); + if (freshIndex) { + copyIndexFromAddonJar(); + } + List entries = indexFile.exists() ? readIndex(indexFile) : new ArrayList<>(); + if (entries == null) { + return false; + } + boolean changed = reconcileIndex(entries, check, freshIndex); + if (entries.isEmpty()) { + return false; + } + phaseIndex = entries; + if (changed && saveIndex()) { + addon.log("Updated " + PHASES_INDEX_YML + " to match the files in the phases folder."); + } + int startBlock = 0; + for (PhaseIndexEntry entry : entries) { + startBlock = loadIndexedPhase(check, entry, startBlock); + } + if (gotoAtEnd != null) { + OneBlockPhase gotoPhase = new OneBlockPhase(String.valueOf(startBlock)); + gotoPhase.setGotoBlock(gotoAtEnd); + blockProbs.put(startBlock, gotoPhase); + } + return true; + } + /** * Copies the shipped phase index from the addon jar, if it has one. */ @@ -177,84 +231,393 @@ private void copyIndexFromAddonJar() { } /** - * 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. + * Reads the phase index file and sets {@link #gotoAtEnd} from it. * - * @param phaseFolder folder holding the phase files - * @param indexFile index file to write + * @param indexFile index file + * @return the entries, or null if the file cannot be parsed */ - void generateIndex(File phaseFolder, File indexFile) { - TreeMap scan = new TreeMap<>(); - Integer gotoTarget = null; + @Nullable + private List readIndex(File indexFile) { + YamlConfiguration index = new YamlConfiguration(); + try { + index.load(indexFile); + } catch (Exception e) { + addon.logError("Could not load " + PHASES_INDEX_YML + ": " + e.getMessage()); + return null; + } + 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); + } + } + gotoAtEnd = index.contains(GOTO_AT_END) ? index.getInt(GOTO_AT_END, 0) : null; + adminLengths = index.getBoolean(ADMIN_LENGTHS, false); + return entries; + } + + /** + * Result of scanning the main phase files in the phases folder: every phase + * section with a numeric (legacy start-block) key, phase sections with any + * other key, plus any goto found. Section keys only need to be numbers for + * the legacy fallback loader - for indexed phases they are just identifiers, + * so custom files may use keys like 'my_phase'. + */ + private static class DiskScan { + final TreeMap phases = new TreeMap<>(); + final List unkeyed = new ArrayList<>(); + Integer gotoTarget; int gotoStart = -1; + + /** + * Length of the phase at this legacy start-block key, from the gap to the + * next scanned key (or the goto), or -1 if there is nothing after it. + */ + int lengthAt(int start) { + Integer next = phases.higherKey(start); + int end = next != null ? next : gotoStart; + return end > start ? end - start : -1; + } + } + + /** + * Scans the main phase files on disk. Only main files are read - they contain + * plain scalars, so this is safe on any server version. Chest files, which + * hold serialized items, are never touched. + * + * @param phaseFolder folder holding the phase files + * @return the scan + */ + private DiskScan scanPhaseFolder(File phaseFolder) { + DiskScan scan = new DiskScan(); 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) { + if (files != null) { + for (File phaseFile : files) { + scanPhaseFile(phaseFile, scan); + } + } + return scan; + } + + /** + * Adds every phase section in one main phase file to the scan. + */ + private void scanPhaseFile(File phaseFile, DiskScan scan) { + YamlConfiguration cfg = new YamlConfiguration(); + try { + cfg.load(phaseFile); + } catch (Exception e) { + addon.logError("Could not scan " + phaseFile.getName() + " for the phase index: " + e.getMessage()); 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()); + for (String key : cfg.getKeys(false)) { + ConfigurationSection section = cfg.getConfigurationSection(key); + if (section != null) { + scanPhaseSection(base, key, section, scan); } - 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); - } + } + } + + /** + * Adds one top-level section of a phase file to the scan - as the goto, a + * numeric-keyed phase, or an unkeyed phase. + */ + private void scanPhaseSection(String base, String key, ConfigurationSection section, DiskScan scan) { + boolean numericKey = NumberUtils.isDigits(key); + if (section.contains(GOTO_BLOCK)) { + scan.gotoTarget = section.getInt(GOTO_BLOCK, 0); + if (numericKey) { + scan.gotoStart = Integer.parseInt(key); } + return; } - if (scan.isEmpty()) { + // Non-numeric keys are fine for indexed phases, but ask for some + // evidence that the section really is a phase so stray YAML in the + // folder does not become one + if (!numericKey && !looksLikePhase(section)) { 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); + 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); } - if (writeIndex(indexFile, entries, gotoTarget)) { - addon.log("Created " + PHASES_INDEX_YML + " from the existing phase files."); + if (numericKey) { + scan.phases.put(Integer.parseInt(key), entry); + } else { + scan.unkeyed.add(entry); } } + private boolean looksLikePhase(ConfigurationSection section) { + return section.contains(NAME) || section.contains(BLOCKS) || section.contains(FIXED_BLOCKS) + || section.contains(MOBS); + } + /** - * Adds an index entry for every non-goto phase section in a scanned file. + * Reconciles the index entries with the phase files actually in the phases + * folder, so the index - and everything driven by it, like the admin phases + * panel - reflects this server's reality rather than a shipped preset: + *
    + *
  • An entry whose file and section exist on disk keeps its place.
  • + *
  • An entry whose file is missing is re-pointed at a folder file holding a + * phase with the same name - this follows shipped file renames across addon + * versions (e.g. 11000_deep_dark to 10500_deep_dark).
  • + *
  • Failing that, a shipped file is restored from the addon jar - this + * recovers phases added by an addon upgrade, whose files are never copied + * into an existing folder.
  • + *
  • Failing that, the entry is removed.
  • + *
  • Folder files not in the index at all are added. Numeric section keys + * position an entry among the others by legacy start block and imply its + * length; non-numeric keys (fine for custom phases - keys are only start + * blocks for the legacy non-index loader) go to the end with the default + * length, to be arranged in the admin GUI.
  • + *
+ * When any of that repair work happened - or the index was only just copied + * from the jar over an existing folder - the index clearly did not describe + * this server, so entry lengths are also refreshed from the gaps between the + * files' legacy start-block keys, preserving the layout the server actually + * ran before the index existed. Once an admin has set lengths through a tool + * ({@link #setAdminLengths()}), lengths are never refreshed. * - * @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 + * @param entries index entries, reconciled in place + * @param phaseFolder folder holding the phase files + * @param refreshLengths true to refresh entry lengths from the folder even if + * no other repair was needed + * @return true if the entries or goto changed and the index should be saved */ - private void scanPhaseFile(YamlConfiguration cfg, String base, TreeMap scan) { - for (String key : cfg.getKeys(false)) { - if (!NumberUtils.isCreatable(key)) { + boolean reconcileIndex(List entries, File phaseFolder, boolean refreshLengths) { + DiskScan disk = scanPhaseFolder(phaseFolder); + boolean fromScratch = entries.isEmpty(); + Reconciliation rec = new Reconciliation(); + for (PhaseIndexEntry entry : entries) { + reconcileEntry(entry, disk, phaseFolder, rec); + } + addKeyedDiscoveries(disk, rec, fromScratch); + addUnkeyedDiscoveries(disk, rec); + if (gotoAtEnd == null && disk.gotoTarget != null) { + gotoAtEnd = disk.gotoTarget; + rec.changed = true; + } + if (refreshLengths || rec.repaired) { + refreshLengthsFromFolder(disk, rec); + } + entries.clear(); + entries.addAll(rec.result); + return rec.changed; + } + + /** + * Working state of one reconciliation run. + */ + private static class Reconciliation { + final List result = new ArrayList<>(); + /** + * Legacy start-block key of the folder section backing each kept entry. + * Null when an entry is not backed by a scanned numeric-keyed section. + */ + final List orderKeys = new ArrayList<>(); + final Set claimedScans = new HashSet<>(); + final Map claims = new HashMap<>(); + boolean changed; + boolean repaired; + + void keep(PhaseIndexEntry entry, @Nullable Integer orderKey) { + result.add(entry); + orderKeys.add(orderKey); + } + } + + /** + * Reconciles one index entry: claims its backing folder section (re-pointing + * the entry if the phase moved), restores its file from the jar, or drops it. + */ + private void reconcileEntry(PhaseIndexEntry entry, DiskScan disk, File phaseFolder, Reconciliation rec) { + PhaseIndexEntry scanned = matchOnDisk(entry, disk, phaseFolder, rec); + if (scanned != null) { + rec.claimedScans.add(scanned); + Integer start = sectionKeyOf(scanned); + if (start != null) { + rec.claims.put(entry, start); + } + rec.keep(entry, start); + return; + } + if (!mainFile(phaseFolder, entry.getFile()).exists()) { + restorePhaseFileFromJar(entry.getFile()); + if (!mainFile(phaseFolder, entry.getFile()).exists()) { + addon.logWarning("Phase index: removed " + entry.getName() + " - " + entry.getFile() + + ".yml is not in the phases folder or the addon jar."); + rec.repaired = true; + rec.changed = true; + return; + } + addon.log("Phase index: restored missing " + entry.getFile() + ".yml from the addon jar."); + rec.repaired = true; + } + // On disk but without a free scanned section - keep it as-is and let + // the loader's section fallback deal with it + rec.keep(entry, sectionKeyOf(entry)); + } + + /** + * Finds the scanned folder section backing this entry: its own file and + * section, else a free section of its file, else - only when its file is + * gone - a section holding a phase with the same name. In the latter two + * cases the entry is re-pointed at what was found. + */ + @Nullable + private PhaseIndexEntry matchOnDisk(PhaseIndexEntry entry, DiskScan disk, File phaseFolder, Reconciliation rec) { + PhaseIndexEntry scanned = findScanned(disk, rec.claimedScans, en -> en.getFile().equals(entry.getFile()) + && (entry.getSection() == null || Objects.equals(entry.getSection(), en.getSection()))); + if (scanned != null) { + return scanned; + } + scanned = findScanned(disk, rec.claimedScans, en -> en.getFile().equals(entry.getFile())); + if (scanned == null && !mainFile(phaseFolder, entry.getFile()).exists()) { + scanned = findScanned(disk, rec.claimedScans, + en -> en.getName() != null && en.getName().equalsIgnoreCase(entry.getName())); + } + if (scanned == null) { + return null; + } + // The same phase lives in a different file or section on disk + addon.log("Phase index: " + entry.getName() + " is " + scanned.getFile() + + ".yml in the phases folder. Using that file."); + entry.setFile(scanned.getFile()); + entry.setSection(scanned.getSection()); + entry.setName(scanned.getName()); + entry.setRequiredMinecraftVersion(scanned.getRequiredMinecraftVersion()); + rec.repaired = true; + rec.changed = true; + return scanned; + } + + /** + * Adds numeric-keyed folder sections the index does not know about, + * positioned by legacy start block with the length its key gap implies. + */ + private void addKeyedDiscoveries(DiskScan disk, Reconciliation rec, boolean fromScratch) { + for (Entry en : disk.phases.entrySet()) { + if (rec.claimedScans.contains(en.getValue())) { continue; } - ConfigurationSection section = cfg.getConfigurationSection(key); - if (section == null || section.contains(GOTO_BLOCK)) { + PhaseIndexEntry entry = en.getValue(); + int length = disk.lengthAt(en.getKey()); + entry.setLength(length > 0 ? length : DEFAULT_PHASE_LENGTH); + int pos = insertPosition(rec.orderKeys, en.getKey()); + rec.result.add(pos, entry); + rec.orderKeys.add(pos, en.getKey()); + rec.claims.put(entry, en.getKey()); + if (!fromScratch) { + addon.log("Phase index: added " + entry.getName() + " from " + entry.getFile() + + ".yml found in the phases folder."); + } + rec.repaired = true; + rec.changed = true; + } + } + + /** + * Adds folder sections without numeric keys to the end of the phase order + * with the default length, for the admin to arrange in the GUI. + */ + private void addUnkeyedDiscoveries(DiskScan disk, Reconciliation rec) { + for (PhaseIndexEntry entry : disk.unkeyed) { + if (rec.claimedScans.contains(entry)) { 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); + entry.setLength(DEFAULT_PHASE_LENGTH); + rec.keep(entry, null); + addon.log("Phase index: added " + entry.getName() + " from " + entry.getFile() + + ".yml at the end of the phase order. Move it with the admin phases GUI."); + rec.repaired = true; + rec.changed = true; + } + } + + /** + * Refreshes claimed entries' lengths from the gaps between the folder + * files' legacy start-block keys, unless an admin owns the lengths. + */ + private void refreshLengthsFromFolder(DiskScan disk, Reconciliation rec) { + if (adminLengths) { + return; + } + for (Entry claim : rec.claims.entrySet()) { + int length = disk.lengthAt(claim.getValue()); + if (length > 0 && length != claim.getKey().getLength()) { + claim.getKey().setLength(length); + rec.changed = true; } - scan.put(Integer.parseInt(key), entry); + } + } + + /** + * Finds the first unclaimed scanned phase section that matches - numeric-keyed + * sections in start-block order first, then the unkeyed ones. + * + * @param disk folder scan + * @param claimed scanned sections already claimed by an index entry + * @param test match condition + * @return matching scanned section, or null + */ + @Nullable + private PhaseIndexEntry findScanned(DiskScan disk, Set claimed, + Predicate test) { + return Stream.concat(disk.phases.values().stream(), disk.unkeyed.stream()) + .filter(en -> !claimed.contains(en) && test.test(en)).findFirst().orElse(null); + } + + /** + * @return the entry's section key as a legacy start block, or null if the + * key is not numeric + */ + @Nullable + private Integer sectionKeyOf(PhaseIndexEntry entry) { + return NumberUtils.isDigits(entry.getSection()) ? Integer.valueOf(entry.getSection()) : null; + } + + /** + * @return the position in the reconciled list where a phase with this legacy + * start-block key belongs - before the first entry with a higher key + */ + private int insertPosition(List orderKeys, int start) { + for (int i = 0; i < orderKeys.size(); i++) { + Integer key = orderKeys.get(i); + if (key != null && key > start) { + return i; + } + } + return orderKeys.size(); + } + + private File mainFile(File phaseFolder, String fileBase) { + return new File(phaseFolder, fileBase + ".yml"); + } + + /** + * Copies a phase's main and chest files from the addon jar, if it ships them. + */ + private void restorePhaseFileFromJar(String fileBase) { + saveJarResource(PHASES + "/" + fileBase + ".yml"); + saveJarResource(PHASES + "/" + fileBase + CHESTS_YML_SUFFIX); + } + + private void saveJarResource(String jarPath) { + try { + addon.saveResource(jarPath, false); + } catch (Exception e) { + // Not shipped in the jar } } @@ -272,6 +635,9 @@ private boolean writeIndex(File indexFile, List entries, @Nulla if (gotoTarget != null) { index.set(GOTO_AT_END, gotoTarget); } + if (adminLengths) { + index.set(ADMIN_LENGTHS, true); + } try { index.save(indexFile); return true; @@ -333,47 +699,14 @@ public void setGotoAtEnd(@Nullable Integer 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 + * Marks the index as holding admin-set phase lengths. Call before + * {@link #saveIndex()} when a tool changes an entry's length. From then on + * reconciliation never overwrites lengths from the phase files' legacy + * start-block keys, so the admin's values stick even when files are later + * added, renamed, or removed. */ - 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; + public void setAdminLengths() { + adminLengths = true; } /** @@ -1282,10 +1615,14 @@ private String getPhaseFileName(OneBlockPhase p) { * 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(); + PhaseIndexEntry indexEntry = p.getIndexEntry(); + String section = indexEntry == null ? null : indexEntry.getSection(); + if (section != null) { + return section; } - return p.getBlockNumber(); + // Block number can be null when the phase came from the database via GSON + String blockNumber = p.getBlockNumber(); + return blockNumber == null ? "0" : blockNumber; } private void saveChests(ConfigurationSection phSec, OneBlockPhase phase) { diff --git a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java index a320a0d..5eb284e 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java @@ -5,8 +5,19 @@ import java.util.List; import java.util.Objects; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; +import org.eclipse.jdt.annotation.Nullable; + +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.oneblocks.OneBlockPhase; @@ -23,8 +34,8 @@ * 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. + * toggles a phase on or off. Shift-left-click asks for a new phase length in + * chat. Changes are saved to the phase index and applied immediately. * * @author tastybento */ @@ -36,6 +47,7 @@ public class AdminPhasesPanel { private static final int HAND_SLOT = 4; private static final int ROW_START = 9; private static final int PANEL_SIZE = 54; + private static final int LENGTH_PROMPT_TIMEOUT_SECONDS = 60; private final AOneBlock addon; private final User user; @@ -114,6 +126,7 @@ private PanelItem phaseItem(PhaseIndexEntry entry, int displayIndex, int startBl } else { description.add(user.getTranslation(REF + "pick-up")); description.add(user.getTranslation(REF + "toggle")); + description.add(user.getTranslation(REF + "set-length")); } return new PanelItemBuilder().icon(phaseIcon(entry)) .name(user.getTranslation(REF + "phase-name", NAME_PLACEHOLDER, entry.getName())) @@ -121,6 +134,8 @@ private PanelItem phaseItem(PhaseIndexEntry entry, int displayIndex, int startBl .clickHandler((panel, u, clickType, slot) -> { if (heldIndex >= 0) { dropAt(displayIndex); + } else if (clickType == ClickType.SHIFT_LEFT) { + promptForLength(entry); } else if (clickType.isRightClick()) { toggle(entry); } else { @@ -176,10 +191,25 @@ private ItemStack phaseIcon(PhaseIndexEntry 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) + .map(this::loadedPhaseIcon).filter(Objects::nonNull).findFirst() .orElseGet(() -> new ItemStack(Material.STONE)); } + /** + * @return the phase's configured icon, falling back to its first block, or + * null if it has neither + */ + private ItemStack loadedPhaseIcon(OneBlockPhase phase) { + if (phase.getIconBlock() != null) { + return phase.getIconBlock().clone(); + } + if (phase.getFirstBlock() != null && phase.getFirstBlock().getMaterial() != null + && phase.getFirstBlock().getMaterial().isItem()) { + return new ItemStack(phase.getFirstBlock().getMaterial()); + } + return null; + } + /** * Picks up the phase at this position in the index. */ @@ -226,6 +256,118 @@ void toggle(PhaseIndexEntry entry) { persist(); } + /** + * Closes the panel and asks the admin for a new length for this phase in + * chat. The prompt shows the current length. A valid number is applied and + * the panel reopens; typing the cancel word or timing out leaves the length + * unchanged. Bukkit's conversation API is deprecated for removal, so this + * listens for the admin's next chat message directly. + */ + void promptForLength(PhaseIndexEntry entry) { + user.closeInventory(); + LengthChatListener listener = new LengthChatListener(entry); + Bukkit.getPluginManager().registerEvents(listener, addon.getPlugin()); + listener.timeoutTask = Bukkit.getScheduler().runTaskLater(addon.getPlugin(), listener::timeout, + 20L * LENGTH_PROMPT_TIMEOUT_SECONDS); + sendLengthPrompt(entry); + } + + private void sendLengthPrompt(PhaseIndexEntry entry) { + user.sendMessage(REF + "enter-length", NAME_PLACEHOLDER, entry.getName(), NUMBER_PLACEHOLDER, + String.valueOf(entry.getLength())); + } + + /** + * Captures the admin's next chat message as the new phase length. Chat + * arrives off the server thread, so the input is applied on the main thread. + */ + class LengthChatListener implements Listener { + + private final PhaseIndexEntry entry; + BukkitTask timeoutTask; + private boolean done; + + LengthChatListener(PhaseIndexEntry entry) { + this.entry = entry; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onChat(AsyncChatEvent event) { + if (!event.getPlayer().getUniqueId().equals(user.getUniqueId())) { + return; + } + event.setCancelled(true); + String input = PlainTextComponentSerializer.plainText().serialize(event.message()).trim(); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> consume(input)); + } + + /** + * Handles one line of chat input on the main thread: cancel word keeps + * the length, a valid number applies it, anything else re-prompts. + */ + void consume(String input) { + if (done) { + return; + } + if (input.equalsIgnoreCase(user.getTranslation(REF + "cancel-word"))) { + finish(); + user.sendMessage(REF + "length-cancelled"); + return; + } + Integer length = parseLength(input); + if (length == null) { + user.sendMessage(REF + "invalid-length"); + sendLengthPrompt(entry); + return; + } + finish(); + setLength(entry, length); + } + + /** + * Gives up waiting for input. + */ + void timeout() { + if (!done) { + timeoutTask = null; + finish(); + user.sendMessage(REF + "length-cancelled"); + } + } + + private void finish() { + done = true; + HandlerList.unregisterAll(this); + if (timeoutTask != null) { + timeoutTask.cancel(); + } + } + } + + /** + * @return the input as a phase length, or null if it is not a whole number + * above 0 + */ + @Nullable + static Integer parseLength(String input) { + if (!input.matches("\\d{1,8}")) { + return null; + } + int length = Integer.parseInt(input); + return length > 0 ? length : null; + } + + /** + * Applies a new length to a phase, marks the index as holding admin-set + * lengths so reconciliation never overwrites them, then saves, reloads, and + * reopens the panel. + */ + void setLength(PhaseIndexEntry entry, int length) { + entry.setLength(length); + manager().setAdminLengths(); + persist(); + } + /** * Saves the index, reloads the phases so the new order takes effect, and * re-renders the panel from the fresh state. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 661bd53..0661c83 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -74,19 +74,28 @@ aoneblock: 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." + instructions: |- + &7 Click a phase to pick it up, + &7 then click where it should go. + &7 Right-click toggles a phase + &7 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" + start: "&7 Start: &b [number]" + length: "&7 Length: &b [number]" 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" + version-locked: "&c Needs Minecraft [version]+" + pick-up: "&e Click to move" + toggle: "&e Right-click to toggle" + set-length: "&e Shift-left-click to set length" + drop-here: "&e Click to drop here" drop-at-end: "&a Drop at the end" held: "&e Moving: [name]" - put-back: "&e Click to put it back" + put-back: "&e Click to put back" + enter-length: "&e Enter a new length in chat for &a [name] &e - it is currently &b [number] &e blocks. Type &c cancel &e to keep it." + invalid-length: "&c The length must be a whole number above 0" + length-cancelled: "&c Length unchanged" + cancel-word: "cancel" 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_index.yml b/src/main/resources/phases_index.yml index 27d32d8..5f1fd7f 100644 --- a/src/main/resources/phases_index.yml +++ b/src/main/resources/phases_index.yml @@ -5,6 +5,13 @@ # that need a newer Minecraft version are skipped without their files (and any # serialized items inside them) ever being touched. # +# The index is reconciled with the phases folder on every load: phase files +# dropped into the folder are added to the index automatically, and entries +# whose files are gone are repaired or removed. Custom phase files may use any +# unique top-level key (e.g. 'my_phase') - numbers are only needed by very old +# addon versions. Files with a numeric key slot in by that legacy start block; +# any other key is added at the end for you to arrange with /oba phases. +# # Each entry: # file: base name of the phase file in the phases folder (without .yml). # The chest file is _chests.yml. @@ -15,6 +22,8 @@ # 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. +# adminLengths: (top level) set automatically once lengths are edited in the +# admin GUI; stops reconciliation from recomputing lengths. # # After the last phase, the block count jumps to gotoAtEnd. phases: diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index 50ab47a..12e28b4 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -907,6 +908,298 @@ void testLoadPhasesMalformedIndexFallsBack() throws IOException { verify(plugin).logWarning(org.mockito.ArgumentMatchers.contains("Phase index could not be used")); } + /** + * An index entry whose file is missing is re-pointed at the folder file that + * holds a phase with the same name, and its length is refreshed from the gaps + * between the folder files' legacy start-block keys. This follows shipped + * file renames across addon versions without losing the server's layout. + */ + @Test + void testReconcileRepointsRenamedFile() throws IOException { + PHASES_DIR.mkdirs(); + java.nio.file.Files.writeString(new File(PHASES_DIR, "old_alpha.yml").toPath(), """ + '0': + name: Alpha + biome: PLAINS + blocks: + GRASS_BLOCK: 100 + """); + java.nio.file.Files.writeString(new File(PHASES_DIR, "beta.yml").toPath(), """ + '500': + name: Beta + biome: PLAINS + blocks: + STONE: 100 + """); + java.nio.file.Files.writeString(new File(PHASES_DIR, "1200_goto_0.yml").toPath(), """ + '1200': + gotoBlock: 0 + """); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: new_alpha + section: '0' + name: Alpha + length: 100 + - file: beta + section: '500' + name: Beta + length: 250 + """); + obm.loadPhases(); + List index = obm.getPhaseIndex(); + assertEquals(2, index.size()); + assertEquals("old_alpha", index.get(0).getFile()); + assertEquals(500, index.get(0).getLength(), "Length should come from the folder's legacy keys"); + assertEquals(700, index.get(1).getLength(), + "A matching entry's length should also be refreshed from the folder once repair was needed"); + assertEquals("Alpha", obm.getPhase(0).getPhaseName()); + assertEquals("Beta", obm.getPhase(500).getPhaseName()); + assertEquals(0, (int) obm.getGotoAtEnd(), "The folder's goto should be adopted"); + assertEquals(0, (int) obm.getPhase(1200).getGotoBlock()); + // The repaired index was saved + String saved = java.nio.file.Files.readString(INDEX_FILE.toPath()); + assertTrue(saved.contains("old_alpha")); + assertFalse(saved.contains("new_alpha")); + verify(plugin, never()).logError(anyString()); + } + + /** + * A phase file in the folder that the index does not know about is added to + * the index, positioned among the other entries by its legacy start-block + * key. Custom phases always show up. + */ + @Test + void testReconcileAddsUnindexedFile() 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, "gamma.yml").toPath(), """ + '250': + name: Gamma + biome: PLAINS + blocks: + DIRT: 100 + """); + java.nio.file.Files.writeString(new File(PHASES_DIR, "beta.yml").toPath(), """ + '500': + name: Beta + biome: PLAINS + blocks: + STONE: 100 + """); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: alpha + section: '0' + name: Alpha + length: 250 + - file: beta + section: '500' + name: Beta + length: 300 + """); + obm.loadPhases(); + List names = obm.getPhaseIndex().stream().map(PhaseIndexEntry::getName).toList(); + assertEquals(List.of("Alpha", "Gamma", "Beta"), names); + assertEquals("Alpha", obm.getPhase(0).getPhaseName()); + assertEquals("Gamma", obm.getPhase(250).getPhaseName()); + assertEquals("Beta", obm.getPhase(500).getPhaseName()); + verify(plugin).log(org.mockito.ArgumentMatchers.contains("added Gamma")); + verify(plugin, never()).logError(anyString()); + } + + /** + * An index entry whose file is neither in the folder nor matched by name is + * restored from the addon jar. This recovers shipped phases added by an + * upgrade, whose files are never copied into an existing phases folder. + */ + @Test + void testReconcileRestoresShippedFileFromJar() throws IOException { + PHASES_DIR.mkdirs(); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: 0_plains + section: '0' + name: Plains + length: 100 + """); + obm.loadPhases(); + assertTrue(new File(PHASES_DIR, "0_plains.yml").exists(), "Phase file should be restored from the jar"); + assertEquals(1, obm.getPhaseIndex().size()); + assertEquals("Plains", obm.getPhase(0).getPhaseName()); + verify(plugin).log(org.mockito.ArgumentMatchers.contains("restored missing 0_plains.yml")); + verify(plugin, never()).logError(anyString()); + } + + /** + * An index entry whose file is gone and cannot be recovered is removed with a + * warning, so the index - and the admin panel built from it - never shows + * phases that do not exist. + */ + @Test + void testReconcileRemovesDeadEntry() 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(), """ + phases: + - file: alpha + section: '0' + name: Alpha + length: 100 + - file: ghost + section: '900' + name: Ghost + length: 100 + """); + obm.loadPhases(); + assertEquals(1, obm.getPhaseIndex().size()); + assertEquals("Alpha", obm.getPhaseIndex().get(0).getName()); + verify(plugin).logWarning(org.mockito.ArgumentMatchers.contains("removed Ghost")); + } + + /** + * Section keys do not have to be numbers: a custom phase file with a plain + * key is discovered, added at the end of the index with the default length, + * its chest file loads (chests pair by file name, not by number), and a + * second load leaves the index unchanged. + */ + @Test + void testReconcileAddsUnkeyedCustomFileWithChests() 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, "custom.yml").toPath(), """ + my_phase: + name: Custom + biome: PLAINS + blocks: + STONE: 100 + """); + java.nio.file.Files.writeString(new File(PHASES_DIR, "custom_chests.yml").toPath(), """ + my_phase: + chests: + '1': + contents: + 0: + ==: 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(); + List index = obm.getPhaseIndex(); + assertEquals(2, index.size()); + assertEquals("Custom", index.get(1).getName()); + assertEquals("my_phase", index.get(1).getSection()); + assertEquals(OneBlocksManager.DEFAULT_PHASE_LENGTH, index.get(1).getLength()); + // Loads right after Alpha, with its chest + OneBlockPhase custom = obm.getPhase(100); + assertEquals("Custom", custom.getPhaseName()); + assertEquals(1, custom.getChests().size()); + verify(plugin).log(org.mockito.ArgumentMatchers.contains("added Custom")); + verify(plugin, never()).logError(anyString()); + // Second load claims the unkeyed section - nothing changes + obm.loadPhases(); + assertEquals(2, obm.getPhaseIndex().size()); + verify(plugin, times(1)).log(org.mockito.ArgumentMatchers.contains("added Custom")); + verify(plugin, never()).logError(anyString()); + } + + /** + * Once an admin has set lengths (adminLengths flag in the index), repair work + * still happens but lengths are never refreshed from the files' legacy keys, + * and the flag survives a save/reload round trip. + */ + @Test + void testReconcileKeepsAdminLengths() 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, "gamma.yml").toPath(), """ + '500': + name: Gamma + biome: PLAINS + blocks: + DIRT: 100 + """); + // Admin-set length 123 differs from the 500 implied by the legacy keys + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: alpha + section: '0' + name: Alpha + length: 123 + adminLengths: true + """); + obm.loadPhases(); + // Gamma was added (a repair) but Alpha's admin-set length stuck + assertEquals(2, obm.getPhaseIndex().size()); + assertEquals(123, obm.getPhaseIndex().get(0).getLength()); + // The flag survived the reconcile-triggered save + String saved = java.nio.file.Files.readString(INDEX_FILE.toPath()); + assertTrue(saved.contains("adminLengths: true")); + verify(plugin, never()).logError(anyString()); + } + + /** + * When index and folder already agree, reconciliation changes nothing and the + * index is not rewritten - admin-set order, lengths, and enabled flags stick. + */ + @Test + void testReconcileNoChangeDoesNotRewriteIndex() 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(), """ + phases: + - file: alpha + section: '0' + name: Alpha + length: 9999 + """); + obm.loadPhases(); + assertEquals(9999, obm.getPhaseIndex().get(0).getLength()); + verify(plugin, never()).log(org.mockito.ArgumentMatchers.contains("Updated")); + verify(plugin, never()).logError(anyString()); + verify(plugin, never()).logWarning(anyString()); + } + /** * 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 index 52226df..f2ebb3c 100644 --- a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java +++ b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java @@ -2,10 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; 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.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -143,6 +145,64 @@ void testToggle() throws IOException { assertTrue(beta.isEnabled()); } + /** + * Setting a length applies the value, marks the index as holding admin-set + * lengths so reconciliation never overwrites them, and persists. + */ + @Test + void testSetLength() throws IOException { + PhaseIndexEntry beta = index.get(1); + panel.setLength(beta, 1234); + assertEquals(1234, beta.getLength()); + verify(obm).setAdminLengths(); + verify(obm).saveIndex(); + verify(obm).loadPhases(); + verify(user).sendMessage("aoneblock.commands.admin.phases.saved"); + } + + /** + * Chat input parsing only accepts whole numbers above zero. + */ + @Test + void testParseLength() { + assertEquals(1, AdminPhasesPanel.parseLength("1")); + assertEquals(500, AdminPhasesPanel.parseLength("500")); + assertNull(AdminPhasesPanel.parseLength("0")); + assertNull(AdminPhasesPanel.parseLength("-5")); + assertNull(AdminPhasesPanel.parseLength("2.5")); + assertNull(AdminPhasesPanel.parseLength("abc")); + assertNull(AdminPhasesPanel.parseLength("")); + } + + /** + * The chat listener applies a valid number, keeps listening on invalid + * input, and keeps the length on the cancel word. + */ + @Test + void testLengthChatListenerConsume() { + when(user.getTranslation("aoneblock.commands.admin.phases.gui.cancel-word")).thenReturn("cancel"); + PhaseIndexEntry beta = index.get(1); + // Invalid input re-prompts and keeps listening + AdminPhasesPanel.LengthChatListener listener = panel.new LengthChatListener(beta); + listener.consume("nonsense"); + verify(user).sendMessage("aoneblock.commands.admin.phases.gui.invalid-length"); + verify(obm, never()).saveIndex(); + // Then a valid number applies + listener.consume("750"); + assertEquals(750, beta.getLength()); + verify(obm).setAdminLengths(); + verify(obm).saveIndex(); + // Further input is ignored once done + listener.consume("999"); + assertEquals(750, beta.getLength()); + // Cancel keeps the length + AdminPhasesPanel.LengthChatListener cancelled = panel.new LengthChatListener(beta); + cancelled.consume("cancel"); + verify(user).sendMessage("aoneblock.commands.admin.phases.gui.length-cancelled"); + assertEquals(750, beta.getLength()); + verify(obm, times(1)).saveIndex(); + } + /** * A failed save tells the admin instead of failing silently. */