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.
*/