From 889123827adfc127dbf74947c1269202be51be89 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 20 Jul 2026 18:14:50 -0700 Subject: [PATCH 1/3] fix: reconcile the phase index with the phases folder on load The 1.26.0 phase index shipped as a preset: upgrading servers got the stock phases_index.yml, which references the current jar's file names. Servers with older or custom phase layouts silently lost every phase whose file name differed, never received Sulfur Caves (phase files are only copied on fresh installs), and the admin phases GUI showed the preset instead of the server's reality. The index is now reconciled with the phases folder on every load - the folder is the source of truth for which phases exist: - 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 (follows shipped file renames), failing that its file is restored from the addon jar (recovers phases added by an upgrade), failing that it is removed. - Folder files not in the index are added. Numeric section keys give a position and length from the legacy start-block layout; non-numeric keys - now fully supported for custom phases, chests pair by file name so they work too - go to the end with the default length for the admin to arrange in the GUI. - When repair was needed, or the index was freshly copied over an existing folder, lengths are refreshed from the gaps between the files' legacy keys, preserving the layout the server actually ran. The admin phases GUI also gains shift-left-click to set a phase's length via a chat prompt that shows the current value. The first such edit writes adminLengths: true to the index, which permanently stops reconciliation from recomputing lengths, so admin values survive later file additions or renames. GUI polish from feedback: phase icons fall back to the phase's first block instead of stone, the how-to-use book wraps onto four lines, and the lore lines are shortened for small screens. Verified end-to-end on a real 26.2 server with a recreated legacy phases folder plus the stock index: all renames re-pointed, Sulfur Caves restored, lengths matched the server's true layout, and a second boot reconciled nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013C4BPNygFKjoQenDwTyMNY --- .../aoneblock/oneblocks/OneBlocksManager.java | 421 ++++++++++++++---- .../aoneblock/panels/AdminPhasesPanel.java | 102 ++++- src/main/resources/locales/en-US.yml | 25 +- src/main/resources/phases_index.yml | 9 + .../oneblocks/OneBlocksManagerTest3.java | 293 ++++++++++++ .../panels/AdminPhasesPanelTest.java | 34 ++ 6 files changed, 780 insertions(+), 104 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index 5081e6c..fc5626b 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; @@ -87,6 +91,7 @@ public class OneBlocksManager { 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 +107,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,6 +132,7 @@ 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()) { @@ -145,17 +156,35 @@ public void loadPhases() throws IOException { // 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()) { + boolean freshIndex = !indexFile.exists(); + if (freshIndex) { 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; + List entries = indexFile.exists() ? readIndex(indexFile) : new ArrayList<>(); + if (entries != null) { + // The folder is the source of truth for which phases exist - reconcile + // the index with it so custom or renamed phase files always load and + // the admin phases panel shows this server's reality, not a preset. + boolean changed = reconcileIndex(entries, check, freshIndex); + if (!entries.isEmpty()) { + 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; + } } // 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."); @@ -177,23 +206,73 @@ 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) { - return; + return scan; } for (File phaseFile : files) { YamlConfiguration cfg = new YamlConfiguration(); @@ -204,57 +283,243 @@ void generateIndex(File phaseFolder, File indexFile) { 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 (section == null) { + continue; + } + boolean numericKey = NumberUtils.isDigits(key); + if (section.contains(GOTO_BLOCK)) { + scan.gotoTarget = section.getInt(GOTO_BLOCK, 0); + if (numericKey) { + scan.gotoStart = Integer.parseInt(key); + } + continue; + } + // 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 && !section.contains(NAME) && !section.contains(BLOCKS) + && !section.contains(FIXED_BLOCKS) && !section.contains(MOBS)) { + 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); + } + if (numericKey) { + scan.phases.put(Integer.parseInt(key), entry); + } else { + scan.unkeyed.add(entry); } } } - 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."); - } + return scan; } /** - * 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(); + 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 section. + List orderKeys = new ArrayList<>(); + Set claimedScans = new HashSet<>(); + Map claims = new HashMap<>(); + boolean changed = false; + boolean repaired = false; + for (PhaseIndexEntry entry : entries) { + PhaseIndexEntry scanned = findScanned(disk, claimedScans, en -> en.getFile().equals(entry.getFile()) + && (entry.getSection() == null || Objects.equals(entry.getSection(), en.getSection()))); + if (scanned == null) { + scanned = findScanned(disk, claimedScans, en -> en.getFile().equals(entry.getFile())); + if (scanned == null && !mainFile(phaseFolder, entry.getFile()).exists()) { + scanned = findScanned(disk, claimedScans, + en -> en.getName() != null && en.getName().equalsIgnoreCase(entry.getName())); + } + if (scanned != 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()); + repaired = true; + changed = true; + } + } + if (scanned != null) { + claimedScans.add(scanned); + Integer start = sectionKeyOf(scanned); + if (start != null) { + claims.put(entry, start); + } + result.add(entry); + orderKeys.add(start); + continue; + } + if (!mainFile(phaseFolder, entry.getFile()).exists()) { + restorePhaseFileFromJar(entry.getFile()); + if (mainFile(phaseFolder, entry.getFile()).exists()) { + addon.log("Phase index: restored missing " + entry.getFile() + ".yml from the addon jar."); + repaired = true; + } else { + addon.logWarning("Phase index: removed " + entry.getName() + " - " + entry.getFile() + + ".yml is not in the phases folder or the addon jar."); + repaired = true; + changed = true; + continue; + } + } + // On disk but without a free scanned section - keep it as-is and let + // the loader's section fallback deal with it + result.add(entry); + orderKeys.add(sectionKeyOf(entry)); + } + // Folder files the index does not know about. Numeric section keys give + // a position and a length; any other key goes to the end with the + // default length for the admin to arrange in the GUI. + for (Entry en : disk.phases.entrySet()) { + if (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(orderKeys, en.getKey()); + result.add(pos, entry); + orderKeys.add(pos, en.getKey()); + claims.put(entry, en.getKey()); + if (!fromScratch) { + addon.log("Phase index: added " + entry.getName() + " from " + entry.getFile() + + ".yml found in the phases folder."); + } + repaired = true; + changed = true; + } + for (PhaseIndexEntry entry : disk.unkeyed) { + if (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); + result.add(entry); + orderKeys.add(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."); + repaired = true; + changed = true; + } + if (gotoAtEnd == null && disk.gotoTarget != null) { + gotoAtEnd = disk.gotoTarget; + changed = true; + } + if ((refreshLengths || repaired) && !adminLengths) { + for (Entry claim : claims.entrySet()) { + int length = disk.lengthAt(claim.getValue()); + if (length > 0 && length != claim.getKey().getLength()) { + claim.getKey().setLength(length); + changed = true; + } + } + } + entries.clear(); + entries.addAll(result); + return changed; + } + + /** + * 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; } - scan.put(Integer.parseInt(key), entry); + } + 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 +537,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 +601,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; } /** diff --git a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java index a320a0d..c848356 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java @@ -5,7 +5,13 @@ import java.util.List; import java.util.Objects; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.conversations.ConversationContext; +import org.bukkit.conversations.ConversationFactory; +import org.bukkit.conversations.NumericPrompt; +import org.bukkit.conversations.Prompt; +import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import world.bentobox.aoneblock.AOneBlock; @@ -23,8 +29,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 */ @@ -114,6 +120,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 +128,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 +185,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 +250,78 @@ 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. + */ + void promptForLength(PhaseIndexEntry entry) { + user.closeInventory(); + new ConversationFactory(addon.getPlugin()) + .withLocalEcho(false) + .withTimeout(60) + .withEscapeSequence(user.getTranslation(REF + "cancel-word")) + .withFirstPrompt(new LengthPrompt(entry)) + .addConversationAbandonedListener(event -> { + if (!event.gracefulExit()) { + user.sendMessage(REF + "length-cancelled"); + } + }) + .buildConversation(user.getPlayer()).begin(); + } + + /** + * Asks for and applies a new phase length. Input handling runs on the chat + * thread, so applying the value is pushed back onto the server thread. + */ + class LengthPrompt extends NumericPrompt { + + private final PhaseIndexEntry entry; + + LengthPrompt(PhaseIndexEntry entry) { + this.entry = entry; + } + + @Override + public String getPromptText(ConversationContext context) { + return user.getTranslation(REF + "enter-length", NAME_PLACEHOLDER, entry.getName(), + NUMBER_PLACEHOLDER, String.valueOf(entry.getLength())); + } + + @Override + protected boolean isNumberValid(ConversationContext context, Number input) { + return input.intValue() > 0 && input.doubleValue() == input.intValue(); + } + + @Override + protected String getInputNotNumericText(ConversationContext context, String invalidInput) { + return user.getTranslation(REF + "invalid-length"); + } + + @Override + protected String getFailedValidationText(ConversationContext context, Number invalidInput) { + return user.getTranslation(REF + "invalid-length"); + } + + @Override + protected Prompt acceptValidatedInput(ConversationContext context, Number input) { + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> setLength(entry, input.intValue())); + return Prompt.END_OF_CONVERSATION; + } + } + + /** + * 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..e906a98 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -907,6 +907,299 @@ 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, org.mockito.Mockito.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..069c127 100644 --- a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java +++ b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java @@ -143,6 +143,40 @@ 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"); + } + + /** + * The chat prompt shows the phase's current length and only accepts whole + * numbers above zero. + */ + @Test + void testLengthPrompt() { + PhaseIndexEntry beta = index.get(1); + AdminPhasesPanel.LengthPrompt prompt = panel.new LengthPrompt(beta); + when(user.getTranslation(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenAnswer(i -> i.getArgument(0, String.class) + ":" + i.getArgument(2, String.class) + ":" + + i.getArgument(4, String.class)); + assertEquals("aoneblock.commands.admin.phases.gui.enter-length:Beta:100", prompt.getPromptText(null)); + assertTrue(prompt.isNumberValid(null, 1)); + assertTrue(prompt.isNumberValid(null, 500)); + assertFalse(prompt.isNumberValid(null, 0)); + assertFalse(prompt.isNumberValid(null, -5)); + assertFalse(prompt.isNumberValid(null, 2.5)); + } + /** * A failed save tells the admin instead of failing silently. */ From 7ea374b0a595f78889f235dbdc8b02f69f58c1d0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 20 Jul 2026 21:19:45 -0700 Subject: [PATCH 2/3] refactor: address SonarCloud findings on PR #541 - Replace the Bukkit conversation API (deprecated for removal in Paper) with a direct AsyncChatEvent listener for the phase length prompt. Same behaviour: prompt shows the current length, whole numbers above zero apply, invalid input re-prompts, the cancel word or a 60 second timeout keeps the length. Input is applied on the main thread. - Cut cognitive complexity: loadPhases delegates to setUpNewFolder and loadUsingIndex; scanPhaseFolder delegates per file and per section; reconcileIndex is decomposed into reconcileEntry, matchOnDisk, addKeyedDiscoveries, addUnkeyedDiscoveries, and refreshLengthsFromFolder around a small Reconciliation state object. No behaviour change - all 552 tests pass unchanged. - Use static imports for Mockito times in tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013C4BPNygFKjoQenDwTyMNY --- .../aoneblock/oneblocks/OneBlocksManager.java | 436 +++++++++++------- .../aoneblock/panels/AdminPhasesPanel.java | 120 +++-- .../oneblocks/OneBlocksManagerTest3.java | 4 +- .../panels/AdminPhasesPanelTest.java | 52 ++- 4 files changed, 390 insertions(+), 222 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index fc5626b..a03c799 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -136,52 +136,10 @@ public void loadPhases() throws IOException { // 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); - } + setUpNewFolder(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); - boolean freshIndex = !indexFile.exists(); - if (freshIndex) { - copyIndexFromAddonJar(); - } - List entries = indexFile.exists() ? readIndex(indexFile) : new ArrayList<>(); - if (entries != null) { - // The folder is the source of truth for which phases exist - reconcile - // the index with it so custom or renamed phase files always load and - // the admin phases panel shows this server's reality, not a preset. - boolean changed = reconcileIndex(entries, check, freshIndex); - if (!entries.isEmpty()) { - 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; - } + if (loadUsingIndex(check)) { + return; } // Fallback - load every phase file directly as before the index existed gotoAtEnd = null; @@ -194,6 +152,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. */ @@ -271,54 +294,71 @@ private DiskScan scanPhaseFolder(File phaseFolder) { 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 scan; + if (files != null) { + for (File phaseFile : files) { + scanPhaseFile(phaseFile, scan); + } } - 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; + 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; + } + 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()); - for (String key : cfg.getKeys(false)) { - ConfigurationSection section = cfg.getConfigurationSection(key); - if (section == null) { - continue; - } - boolean numericKey = NumberUtils.isDigits(key); - if (section.contains(GOTO_BLOCK)) { - scan.gotoTarget = section.getInt(GOTO_BLOCK, 0); - if (numericKey) { - scan.gotoStart = Integer.parseInt(key); - } - continue; - } - // 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 && !section.contains(NAME) && !section.contains(BLOCKS) - && !section.contains(FIXED_BLOCKS) && !section.contains(MOBS)) { - 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); - } - if (numericKey) { - scan.phases.put(Integer.parseInt(key), entry); - } else { - scan.unkeyed.add(entry); - } + } + } + + /** + * 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; + } + // 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; + } + 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 (numericKey) { + scan.phases.put(Integer.parseInt(key), entry); + } else { + scan.unkeyed.add(entry); } - return scan; + } + + private boolean looksLikePhase(ConfigurationSection section) { + return section.contains(NAME) || section.contains(BLOCKS) || section.contains(FIXED_BLOCKS) + || section.contains(MOBS); } /** @@ -356,112 +396,168 @@ private DiskScan scanPhaseFolder(File phaseFolder) { boolean reconcileIndex(List entries, File phaseFolder, boolean refreshLengths) { DiskScan disk = scanPhaseFolder(phaseFolder); boolean fromScratch = entries.isEmpty(); - 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 section. - List orderKeys = new ArrayList<>(); - Set claimedScans = new HashSet<>(); - Map claims = new HashMap<>(); - boolean changed = false; - boolean repaired = false; + Reconciliation rec = new Reconciliation(); for (PhaseIndexEntry entry : entries) { - PhaseIndexEntry scanned = findScanned(disk, claimedScans, en -> en.getFile().equals(entry.getFile()) - && (entry.getSection() == null || Objects.equals(entry.getSection(), en.getSection()))); - if (scanned == null) { - scanned = findScanned(disk, claimedScans, en -> en.getFile().equals(entry.getFile())); - if (scanned == null && !mainFile(phaseFolder, entry.getFile()).exists()) { - scanned = findScanned(disk, claimedScans, - en -> en.getName() != null && en.getName().equalsIgnoreCase(entry.getName())); - } - if (scanned != 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()); - repaired = true; - changed = true; - } - } - if (scanned != null) { - claimedScans.add(scanned); - Integer start = sectionKeyOf(scanned); - if (start != null) { - claims.put(entry, start); - } - result.add(entry); - orderKeys.add(start); - continue; + 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()) { - restorePhaseFileFromJar(entry.getFile()); - if (mainFile(phaseFolder, entry.getFile()).exists()) { - addon.log("Phase index: restored missing " + entry.getFile() + ".yml from the addon jar."); - repaired = true; - } else { - addon.logWarning("Phase index: removed " + entry.getName() + " - " + entry.getFile() - + ".yml is not in the phases folder or the addon jar."); - repaired = true; - changed = true; - continue; - } + 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; } - // On disk but without a free scanned section - keep it as-is and let - // the loader's section fallback deal with it - result.add(entry); - orderKeys.add(sectionKeyOf(entry)); + addon.log("Phase index: restored missing " + entry.getFile() + ".yml from the addon jar."); + rec.repaired = true; } - // Folder files the index does not know about. Numeric section keys give - // a position and a length; any other key goes to the end with the - // default length for the admin to arrange in the GUI. + // 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 (claimedScans.contains(en.getValue())) { + if (rec.claimedScans.contains(en.getValue())) { continue; } PhaseIndexEntry entry = en.getValue(); int length = disk.lengthAt(en.getKey()); entry.setLength(length > 0 ? length : DEFAULT_PHASE_LENGTH); - int pos = insertPosition(orderKeys, en.getKey()); - result.add(pos, entry); - orderKeys.add(pos, en.getKey()); - claims.put(entry, en.getKey()); + 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."); } - repaired = true; - changed = true; + 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 (claimedScans.contains(entry)) { + if (rec.claimedScans.contains(entry)) { continue; } entry.setLength(DEFAULT_PHASE_LENGTH); - result.add(entry); - orderKeys.add(null); + 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."); - repaired = true; - changed = true; + rec.repaired = true; + rec.changed = true; } - if (gotoAtEnd == null && disk.gotoTarget != null) { - gotoAtEnd = disk.gotoTarget; - changed = true; - } - if ((refreshLengths || repaired) && !adminLengths) { - for (Entry claim : claims.entrySet()) { - int length = disk.lengthAt(claim.getValue()); - if (length > 0 && length != claim.getKey().getLength()) { - claim.getKey().setLength(length); - 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; } } - entries.clear(); - entries.addAll(result); - return changed; } /** diff --git a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java index c848356..5eb284e 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/AdminPhasesPanel.java @@ -7,12 +7,17 @@ import org.bukkit.Bukkit; import org.bukkit.Material; -import org.bukkit.conversations.ConversationContext; -import org.bukkit.conversations.ConversationFactory; -import org.bukkit.conversations.NumericPrompt; -import org.bukkit.conversations.Prompt; +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; @@ -42,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; @@ -254,61 +260,101 @@ void toggle(PhaseIndexEntry entry) { * 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. + * 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(); - new ConversationFactory(addon.getPlugin()) - .withLocalEcho(false) - .withTimeout(60) - .withEscapeSequence(user.getTranslation(REF + "cancel-word")) - .withFirstPrompt(new LengthPrompt(entry)) - .addConversationAbandonedListener(event -> { - if (!event.gracefulExit()) { - user.sendMessage(REF + "length-cancelled"); - } - }) - .buildConversation(user.getPlayer()).begin(); + 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())); } /** - * Asks for and applies a new phase length. Input handling runs on the chat - * thread, so applying the value is pushed back onto the server thread. + * 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 LengthPrompt extends NumericPrompt { + class LengthChatListener implements Listener { private final PhaseIndexEntry entry; + BukkitTask timeoutTask; + private boolean done; - LengthPrompt(PhaseIndexEntry entry) { + LengthChatListener(PhaseIndexEntry entry) { this.entry = entry; } - @Override - public String getPromptText(ConversationContext context) { - return user.getTranslation(REF + "enter-length", NAME_PLACEHOLDER, entry.getName(), - NUMBER_PLACEHOLDER, String.valueOf(entry.getLength())); + @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)); } - @Override - protected boolean isNumberValid(ConversationContext context, Number input) { - return input.intValue() > 0 && input.doubleValue() == input.intValue(); + /** + * 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); } - @Override - protected String getInputNotNumericText(ConversationContext context, String invalidInput) { - return user.getTranslation(REF + "invalid-length"); + /** + * Gives up waiting for input. + */ + void timeout() { + if (!done) { + timeoutTask = null; + finish(); + user.sendMessage(REF + "length-cancelled"); + } } - @Override - protected String getFailedValidationText(ConversationContext context, Number invalidInput) { - return user.getTranslation(REF + "invalid-length"); + private void finish() { + done = true; + HandlerList.unregisterAll(this); + if (timeoutTask != null) { + timeoutTask.cancel(); + } } + } - @Override - protected Prompt acceptValidatedInput(ConversationContext context, Number input) { - Bukkit.getScheduler().runTask(addon.getPlugin(), () -> setLength(entry, input.intValue())); - return Prompt.END_OF_CONVERSATION; + /** + * @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; } /** diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index e906a98..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; @@ -1126,8 +1127,7 @@ void testReconcileAddsUnkeyedCustomFileWithChests() throws IOException { // Second load claims the unkeyed section - nothing changes obm.loadPhases(); assertEquals(2, obm.getPhaseIndex().size()); - verify(plugin, org.mockito.Mockito.times(1)) - .log(org.mockito.ArgumentMatchers.contains("added Custom")); + verify(plugin, times(1)).log(org.mockito.ArgumentMatchers.contains("added Custom")); verify(plugin, never()).logError(anyString()); } diff --git a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java index 069c127..cfef14e 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; @@ -159,22 +161,46 @@ void testSetLength() throws IOException { } /** - * The chat prompt shows the phase's current length and only accepts whole - * numbers above zero. + * Chat input parsing only accepts whole numbers above zero. */ @Test - void testLengthPrompt() { + 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() throws IOException { + when(user.getTranslation("aoneblock.commands.admin.phases.gui.cancel-word")).thenReturn("cancel"); PhaseIndexEntry beta = index.get(1); - AdminPhasesPanel.LengthPrompt prompt = panel.new LengthPrompt(beta); - when(user.getTranslation(anyString(), anyString(), anyString(), anyString(), anyString())) - .thenAnswer(i -> i.getArgument(0, String.class) + ":" + i.getArgument(2, String.class) + ":" - + i.getArgument(4, String.class)); - assertEquals("aoneblock.commands.admin.phases.gui.enter-length:Beta:100", prompt.getPromptText(null)); - assertTrue(prompt.isNumberValid(null, 1)); - assertTrue(prompt.isNumberValid(null, 500)); - assertFalse(prompt.isNumberValid(null, 0)); - assertFalse(prompt.isNumberValid(null, -5)); - assertFalse(prompt.isNumberValid(null, 2.5)); + // 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(); } /** From f60de4a60311b96157a589b2f80661769f8f0db4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 20 Jul 2026 21:22:32 -0700 Subject: [PATCH 3/3] refactor: drop unthrowable IOException declaration in panel test Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013C4BPNygFKjoQenDwTyMNY --- .../world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java index cfef14e..f2ebb3c 100644 --- a/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java +++ b/src/test/java/world/bentobox/aoneblock/panels/AdminPhasesPanelTest.java @@ -179,7 +179,7 @@ void testParseLength() { * input, and keeps the length on the cancel word. */ @Test - void testLengthChatListenerConsume() throws IOException { + 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