merged = merge(templateStream, configFile, ignoredKeys);
+ Files.write(configFile.toPath(), merged);
+ logger.info("Configuration file synced: " + configFile.getName());
+ } catch (Exception e) {
+ if (backup != null && backup.exists()) {
+ Files.copy(backup.toPath(), configFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ logger.severe("Config sync failed, restored backup: " + e.getMessage());
+ }
+ throw e;
}
}
- // ── Merge ─────────────────────────────────────────────────────────────────
+ // ── merge ─────────────────────────────────────────────────────────────────
- /**
- * Merges default lines into disk lines.
- *
- * Strategy: walk disk lines first, emit them as-is. Then walk default
- * lines; for each key block (comment + key line) whose key is absent on
- * disk and not ignored, append it at the correct indentation level.
- */
- private static List merge(
- List defaultLines,
- List diskLines,
- Set diskKeys,
- Set ignored
- ) {
- // We need to insert missing keys at the right position.
- // Build a copy of disk lines we will extend.
- List result = new ArrayList<>(diskLines);
+ @NotNull
+ private List merge(
+ @NotNull InputStream templateStream,
+ @NotNull File existingFile,
+ @Nullable List ignoredKeys) throws IOException {
- // Collect blocks from the default file: each block = list of comment
- // lines + the key line itself. We track the "path stack" to know the
- // full dotted path of every key.
- Deque pathStack = new ArrayDeque<>();
- List pendingComments = new ArrayList<>();
+ List templateLines = readLines(templateStream);
+ List existingLines = existingFile.exists() ? readLines(existingFile) : List.of();
- for (int i = 0; i < defaultLines.size(); i++) {
- String raw = defaultLines.get(i);
- String trimmed = raw.stripLeading();
+ Set ignored = normalizeIgnored(ignoredKeys);
+ Map existingValues = extractFullPathValues(existingLines);
- // Blank line → flush pending comments, keep as separator
- if (trimmed.isBlank()) {
- pendingComments.add(raw);
+ List result = new ArrayList<>();
+ Deque stack = new ArrayDeque<>();
+ int prevIndent = -1;
+
+ for (String line : templateLines) {
+ String trimmed = line.trim();
+
+ if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
+ result.add(line);
continue;
}
- // Comment line → accumulate
- if (trimmed.startsWith("#")) {
- pendingComments.add(raw);
- continue;
+ int colon = trimmed.indexOf(':');
+ if (colon == -1) { result.add(line); continue; }
+
+ String leaf = trimmed.substring(0, colon).trim();
+ String after = trimmed.substring(colon + 1).trim();
+ int indent = indentOf(line);
+
+ while (prevIndent >= 0 && indent <= prevIndent) {
+ if (!stack.isEmpty()) stack.pop();
+ prevIndent -= 2;
}
- // Key line
- int indent = raw.length() - trimmed.length();
- int depth = indent / 2; // assume 2-space indentation
+ String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
+ boolean section = after.isEmpty() || after.startsWith("#");
- // Pop stack back to current depth
- while (pathStack.size() > depth) pathStack.pollLast();
-
- // Parse key name (everything before ':')
- String keyPart = trimmed.split(":")[0].trim();
- pathStack.addLast(keyPart);
- String fullPath = String.join(".", pathStack);
-
- boolean isSection = trimmed.endsWith(":") || trimmed.matches(".*:\\s*$");
- boolean isIgnored = isIgnored(fullPath, ignored);
-
- if (!isIgnored && !diskKeys.contains(fullPath) && !isSection) {
- // This key is missing on disk — inject it
- // Find the best insertion point: after the last line that
- // belongs to the parent section on disk
- int insertAt = findInsertionPoint(result, pathStack, depth);
-
- // Insert blank separator + comments + key line
- List block = new ArrayList<>();
- if (insertAt == result.size() || !result.get(Math.max(0, insertAt - 1)).isBlank()) {
- block.add("");
- }
- block.addAll(pendingComments);
- block.add(raw);
- result.addAll(insertAt, block);
+ if (section) {
+ stack.push(leaf);
+ prevIndent = indent;
+ result.add(line);
+ } else if (isIgnored(fullPath, ignored)) {
+ result.add(line);
+ prevIndent = indent;
+ } else {
+ String existing = existingValues.get(fullPath);
+ result.add(existing != null
+ ? line.substring(0, line.indexOf(':') + 1) + " " + existing
+ : line);
+ prevIndent = indent;
}
-
- // Clear pending comments regardless
- pendingComments.clear();
}
-
return result;
}
- /**
- * Finds the line index where a missing key should be inserted.
- * Inserts after the last line of its parent section.
- */
- private static int findInsertionPoint(List lines, Deque pathStack, int depth) {
- if (depth == 0) {
- // Top-level key → append at end
- return lines.size();
- }
+ @NotNull
+ private Map extractFullPathValues(@NotNull List lines) {
+ Map values = new LinkedHashMap<>();
+ Deque stack = new ArrayDeque<>();
+ int prevIndent = -1;
- // Parent path
- List parts = new ArrayList<>(pathStack);
- String parentKey = parts.get(depth - 1);
- int parentIndent = (depth - 1) * 2;
+ for (String line : lines) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
- // Find the parent key line in the result
- int parentLine = -1;
- for (int i = 0; i < lines.size(); i++) {
- String l = lines.get(i);
- int ind = l.length() - l.stripLeading().length();
- if (ind == parentIndent && l.stripLeading().startsWith(parentKey + ":")) {
- parentLine = i;
- break;
+ int colon = trimmed.indexOf(':');
+ if (colon == -1) continue;
+
+ String leaf = trimmed.substring(0, colon).trim();
+ String after = trimmed.substring(colon + 1).trim();
+ int indent = indentOf(line);
+
+ while (prevIndent >= 0 && indent <= prevIndent) {
+ if (!stack.isEmpty()) stack.pop();
+ prevIndent -= 2;
}
+
+ String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
+
+ if (after.isEmpty() || after.startsWith("#")) {
+ stack.push(leaf);
+ } else {
+ values.put(fullPath, after);
+ }
+ prevIndent = indent;
}
-
- if (parentLine < 0) return lines.size();
-
- // Find end of parent section: next line with same or lower indent that isn't blank/comment
- for (int i = parentLine + 1; i < lines.size(); i++) {
- String l = lines.get(i);
- if (l.isBlank() || l.stripLeading().startsWith("#")) continue;
- int ind = l.length() - l.stripLeading().length();
- if (ind <= parentIndent) return i;
- }
-
- return lines.size();
+ return values;
}
- // ── IO helpers ─────────────────────────────────────────────────────────────
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private static int indentOf(@NotNull String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') i++;
+ return i;
+ }
- /** Reads a resource from the jar as a list of lines. Returns null if not found. */
@Nullable
- private static List readResource(Plugin plugin, String name) {
- try (InputStream in = plugin.getResource(name)) {
- if (in == null) return null;
- return readLines(in);
- } catch (IOException ex) {
- plugin.getLogger().log(Level.SEVERE, "Failed to read resource: " + name, ex);
+ private File createBackup(@NotNull File file) {
+ if (!file.exists()) return null;
+ try {
+ File bak = new File(file.getParent(), file.getName() + ".bak");
+ Files.copy(file.toPath(), bak.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ return bak;
+ } catch (IOException e) {
+ logger.warning("Failed to create backup: " + e.getMessage());
return null;
}
}
- private static List readLines(File file) throws IOException {
- try (BufferedReader r = new BufferedReader(
- new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
- return readLines(r);
+ @NotNull
+ private static List readLines(@NotNull InputStream is) throws IOException {
+ try (var r = new BufferedReader(new InputStreamReader(is))) {
+ return r.lines().toList();
}
}
- private static List readLines(InputStream in) throws IOException {
- try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
- return readLines(r);
- }
+ @NotNull
+ private static List readLines(@NotNull File file) throws IOException {
+ return Files.readAllLines(file.toPath());
}
- private static List readLines(BufferedReader reader) throws IOException {
- List lines = new ArrayList<>();
- String line;
- while ((line = reader.readLine()) != null) lines.add(line);
- return lines;
+ @NotNull
+ private static Set normalizeIgnored(@Nullable List keys) {
+ if (keys == null) return Set.of();
+ Set s = new HashSet<>();
+ for (String k : keys) s.add(k.trim().toLowerCase());
+ return s;
}
- private static void writeLines(File file, List lines) throws IOException {
- try (BufferedWriter w = new BufferedWriter(
- new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
- for (int i = 0; i < lines.size(); i++) {
- w.write(lines.get(i));
- if (i < lines.size() - 1) w.newLine();
- }
- }
- }
-
- // ── Key extraction ─────────────────────────────────────────────────────────
-
- /**
- * Returns the flat set of dotted-path keys present in the file.
- * Only leaf keys (not sections) are returned.
- */
- private static Set flatKeys(File file) throws IOException {
- Set keys = new LinkedHashSet<>();
- Deque stack = new ArrayDeque<>();
-
- List lines = readLines(file);
- for (String raw : lines) {
- String trimmed = raw.stripLeading();
- if (trimmed.isBlank() || trimmed.startsWith("#")) continue;
-
- int indent = raw.length() - trimmed.length();
- int depth = indent / 2;
-
- while (stack.size() > depth) stack.pollLast();
-
- if (!trimmed.contains(":")) continue;
- String keyPart = trimmed.split(":")[0].trim();
- stack.addLast(keyPart);
-
- boolean isSection = trimmed.matches("[^:]+:\\s*$");
- if (!isSection) {
- keys.add(String.join(".", stack));
- }
- }
- return keys;
- }
-
- // ── Helpers ────────────────────────────────────────────────────────────────
-
- private static Set normalizedIgnored(@Nullable List list) {
- if (list == null || list.isEmpty()) return Collections.emptySet();
- Set result = new LinkedHashSet<>();
- for (String s : list) result.add(s.toLowerCase(Locale.ROOT).trim());
- return result;
- }
-
- private static boolean isIgnored(String fullPath, Set ignored) {
- String lower = fullPath.toLowerCase(Locale.ROOT);
- for (String prefix : ignored) {
- if (lower.equals(prefix) || lower.startsWith(prefix + ".")) return true;
+ private static boolean isIgnored(@NotNull String fullPath, @NotNull Set ignored) {
+ String lower = fullPath.toLowerCase();
+ for (String ign : ignored) {
+ if (lower.equals(ign) || lower.startsWith(ign + ".")) return true;
}
return false;
}
-
- private static int countNew(List before, List after) {
- return Math.max(0, after.size() - before.size());
- }
}
diff --git a/src/main/java/dev/loki/loparkour/config/GenerationOptions.java b/src/main/java/dev/loki/loparkour/config/GenerationOptions.java
deleted file mode 100644
index 3f123b6..0000000
--- a/src/main/java/dev/loki/loparkour/config/GenerationOptions.java
+++ /dev/null
@@ -1,117 +0,0 @@
-package dev.loki.loparkour.config;
-
-import dev.loki.loparkour.LoParkour;
-
-/**
- * Generation and jump-validation settings. Populated by {@link Option#init}.
- */
-public final class GenerationOptions {
-
- // Block type chances
- public static double TYPE_NORMAL;
- public static double TYPE_SPECIAL;
- public static double TYPE_SCHEMATICS;
-
- // Special block chances
- public static double SPECIAL_ICE;
- public static double SPECIAL_SLAB;
- public static double SPECIAL_PANE;
- public static double SPECIAL_FENCE;
-
- // Distance weights
- public static double NORMAL_DISTANCE_1;
- public static double NORMAL_DISTANCE_2;
- public static double NORMAL_DISTANCE_3;
- public static double NORMAL_DISTANCE_4;
-
- // Height weights
- public static double NORMAL_HEIGHT_1;
- public static double NORMAL_HEIGHT_0;
- public static double NORMAL_HEIGHT_NEG1;
- public static double NORMAL_HEIGHT_NEG2;
-
- // World bounds
- public static int MAX_Y;
- public static int MIN_Y;
-
- // Jump validation
- public static boolean JUMP_VALIDATION_ENABLED;
- public static double MAX_JUMP_DISTANCE;
- public static double MAX_HORIZONTAL_DISTANCE;
- public static double MAX_VERTICAL_UP;
- public static double MAX_VERTICAL_DOWN;
-
- // Jump types
- public static boolean JUMP_TYPES_ENABLED;
- public static java.util.Map JUMP_TYPE_ENABLED;
- public static java.util.Map JUMP_TYPE_CHANCE;
-
- // Memory optimisation
- public static int BLOCK_CLEANUP_DISTANCE;
- public static int CLEANUP_INTERVAL;
-
- // Ghost mode
- public static boolean GHOST_MODE_ENABLED;
- public static int GHOST_SHOW_TOP;
- public static double GHOST_TRANSPARENCY;
-
- static void init() {
- // Jump validation
- JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled");
- MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0;
- MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0;
- MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0;
- MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0;
-
- // Jump types
- JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled");
- JUMP_TYPE_ENABLED = new java.util.HashMap<>();
- JUMP_TYPE_CHANCE = new java.util.HashMap<>();
- if (Config.CONFIG.isPath("jump-types.types")) {
- for (String type : Config.CONFIG.getChildren("jump-types.types")) {
- String path = "jump-types.types." + type;
- JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(path + ".enabled") && Config.CONFIG.getBoolean(path + ".enabled"));
- JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(path + ".chance") ? Config.CONFIG.getDouble(path + ".chance") : 0.0);
- }
- }
-
- // Memory
- BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100;
- CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100;
-
- // Ghost
- GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled");
- GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3;
- GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5;
-
- // Generation chances
- TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0;
- TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0;
- TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0;
-
- SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0;
- SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0;
- SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0;
- SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0;
-
- NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0;
- NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0;
- NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0;
- NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0;
-
- NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0;
- NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0;
- NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0;
- NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0;
-
- MAX_Y = Config.GENERATION.getInt("generation.settings.max-y");
- MIN_Y = Config.GENERATION.getInt("generation.settings.min-y");
-
- if (MIN_Y >= MAX_Y) {
- MIN_Y = 100; MAX_Y = 200;
- LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200.");
- }
- }
-
- private GenerationOptions() {}
-}
diff --git a/src/main/java/dev/loki/loparkour/config/Locales.java b/src/main/java/dev/loki/loparkour/config/Locales.java
index f1691fc..20b1482 100644
--- a/src/main/java/dev/loki/loparkour/config/Locales.java
+++ b/src/main/java/dev/loki/loparkour/config/Locales.java
@@ -1,13 +1,10 @@
package dev.loki.loparkour.config;
-import java.util.ArrayList;
-
-import dev.loki.loparkour.util.Item;
-
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.player.ParkourUser;
-
+import dev.loki.loparkour.util.Item;
+import dev.loki.loparkour.util.Materials;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
@@ -30,244 +27,194 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
/**
- * Locale message/item handler.
+ * Locale system: loading, caching, validation, and access.
*/
public class Locales {
- // a list of all nodes, used to check against missing nodes
- private static List resourceNodes;
+ private static final Map locales = new HashMap<>();
+ private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("%[a-z]");
- /**
- * A map of all locales with their respective yml trees
- */
- public static final Map locales = new HashMap<>();
+ // ── public API ────────────────────────────────────────────────────────────
- /**
- * Initializes this Locale handler.
- */
public static void init() {
+ loadLocalesAsync();
+ }
+
+ @NotNull
+ public static String getString(@NotNull Player player, @NotNull String path) {
+ return getString(getPlayerLocale(player), path);
+ }
+
+ @NotNull
+ public static String getString(@NotNull String locale, @NotNull String path) {
+ return cachedValue(locale, c -> c.getString(path), "");
+ }
+
+ @NotNull
+ public static List getStringList(@NotNull String locale, @NotNull String path) {
+ return cachedValue(locale, c -> c.getStringList(path), Collections.emptyList());
+ }
+
+ public static int getLocaleCount() {
+ synchronized (locales) { return locales.size(); }
+ }
+
+ @NotNull
+ public static Set getLocaleKeys() {
+ synchronized (locales) { return new HashSet<>(locales.keySet()); }
+ }
+
+ @NotNull
+ public static Item getItem(@NotNull Player player, @NotNull String path, String... replace) {
+ return getItem(getPlayerLocale(player), path, replace);
+ }
+
+ @NotNull
+ public static Item getItem(@NotNull String locale, @NotNull String path, String... replace) {
+ synchronized (locales) {
+ if (locales.isEmpty()) return new Item(Material.STONE, "");
+ FileConfiguration config = locales.get(locale);
+ if (config == null) return new Item(Material.STONE, "");
+ return buildItem(config, path, replace);
+ }
+ }
+
+ // ── loading (was LocaleLoader) ────────────────────────────────────────────
+
+ private static void loadLocalesAsync() {
Plugin plugin = LoParkour.getPlugin();
-
- // Create a temporary map to avoid clearing locales before new ones are loaded
- Map newLocales = new HashMap<>();
-
Scheduler.get(plugin).runAsync(() -> {
- FileConfiguration embedded = YamlConfiguration.loadConfiguration(new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8));
+ try {
+ FileConfiguration embedded = YamlConfiguration.loadConfiguration(
+ new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8));
- // get all nodes from the plugin's english resource, aka the most updated version
- resourceNodes = getChildren(embedded, "", true);
+ Map loaded = loadFromDisk(embedded);
- File folder = LoParkour.getInFolder("locales");
-
- // download files to locales folder
- if (!folder.exists()) {
- folder.mkdirs();
- }
-
- String[] files = folder.list();
-
- // create non-existent files
- if (files != null && files.length == 0) {
- plugin.saveResource("locales/en.yml", false);
- plugin.saveResource("locales/ru.yml", false);
- }
-
- // get all files in locales folder
- try (Stream stream = Files.list(folder.toPath())) {
- stream.forEach(path -> {
- File file = path.toFile();
-
- // get locale from file name
- String locale = file.getName().split("\\.")[0];
-
- LoParkour.log("Found locale " + locale);
-
- FileConfiguration config = YamlConfiguration.loadConfiguration(file);
- validate(embedded, config, file);
-
- newLocales.put(locale, config);
+ Scheduler.get(plugin).run(() -> {
+ synchronized (locales) {
+ locales.clear();
+ locales.putAll(loaded);
+ }
+ LoParkour.log("Locales reloaded successfully (" + loaded.size() + " locales)");
});
} catch (Exception ex) {
- LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + ex.getMessage());
+ plugin.getLogger().severe("Error while loading locale files: " + ex.getMessage());
}
-
- // Only clear and replace locales after all new ones are loaded
- Scheduler.get(plugin).run(() -> {
- locales.clear();
- locales.putAll(newLocales);
- LoParkour.log("Locales reloaded successfully (" + locales.size() + " locales)");
- });
});
}
- // validates whether a lang file contains all required keys.
- // if it doesn't, automatically add them
- private static void validate(FileConfiguration provided, FileConfiguration user, File localPath) {
- List userNodes = getChildren(user, "", true);
-
- for (String node : resourceNodes) {
- if (userNodes.contains(node)) {
- continue;
- }
-
- LoParkour.log("Fixing missing config node %s in %s".formatted(node, localPath.getName()));
-
- user.set(node, provided.get(node));
- }
-
- LoParkour.log("Validated locale " + localPath.getName());
-
- try {
- user.save(localPath);
- } catch (IOException ex) {
- LoParkour.getPlugin().getLogger().severe("Error while trying to save fixed config file %s - delete this file and restart your server - ".formatted(localPath) + ex.getMessage());
- }
- }
-
- /**
- * Gets a String from the provided path in the provided player's locale.
- * If the player is a {@link ParkourUser}, their locale value will be used.
- * If not, the default locale will be used.
- *
- * @param player The player
- * @param path The path
- * @return a String
- */
- public static String getString(Player player, String path) {
- ParkourUser user = ParkourUser.getUser(player);
-
- String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
-
- return getString(locale, path);
- }
-
- /**
- * Gets a coloured String from the provided path in the provided locale file
- *
- * @param locale The locale
- * @param path The path
- * @return a String
- */
- public static String getString(String locale, String path) {
- return getValue(locale, config -> config.getString(path), "");
- }
-
- /**
- * Gets an uncoloured String list from the provided path in the provided locale file
- *
- * @param locale The locale
- * @param path The path
- * @return a String list
- */
- public static List getStringList(String locale, String path) {
- return getValue(locale, config -> config.getStringList(path), Collections.emptyList());
- }
-
- private static T getValue(String locale, Function f, T def) {
- if (locales.isEmpty()) {
- return def;
- }
-
- FileConfiguration config = locales.get(locale);
-
- return config != null ? f.apply(config) : def;
- }
-
- /**
- * Returns an item from a json locale file.
- * The locale is derived from the player.
- * If the player is a {@link ParkourUser}, their locale value will be used.
- * If not, the default locale will be used.
- *
- * @param player The player
- * @param path The full path of the item in the locale file
- * @return a non-null {@link Item} instance built from the description in the locale file
- */
@NotNull
- public static Item getItem(@NotNull Player player, String path, String... replace) {
- ParkourUser user = ParkourUser.getUser(player);
- String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
+ private static Map loadFromDisk(@NotNull FileConfiguration embedded) throws Exception {
+ Map result = new HashMap<>();
+ File folder = LoParkour.getInFolder("locales");
+ ensureFolder(folder);
- return getItem(locale, path, replace);
+ try (Stream stream = Files.list(folder.toPath())) {
+ stream.forEach(path -> {
+ File file = path.toFile();
+ if (!file.getName().endsWith(".yml")) return;
+
+ String locale = file.getName().split("\\.")[0];
+ LoParkour.log("Found locale " + locale);
+
+ FileConfiguration config = YamlConfiguration.loadConfiguration(file);
+ validateAndFix(embedded, config, file);
+ result.put(locale, config);
+ });
+ }
+ return result;
}
- private static final Pattern pattern = Pattern.compile("%[a-z]");
+ private static void ensureFolder(@NotNull File folder) {
+ if (!folder.exists()) folder.mkdirs();
+ String[] files = folder.list();
+ if (files == null || files.length == 0) {
+ Plugin plugin = LoParkour.getPlugin();
+ plugin.saveResource("locales/en.yml", false);
+ plugin.saveResource("locales/ru.yml", false);
+ }
+ }
+
+ // ── validation (was LocaleValidator) ──────────────────────────────────────
+
+ private static void validateAndFix(@NotNull FileConfiguration reference, @NotNull FileConfiguration locale, @NotNull File file) {
+ List refNodes = getChildren(reference);
+ List locNodes = getChildren(locale);
+
+ boolean modified = false;
+ for (String node : refNodes) {
+ if (!locNodes.contains(node)) {
+ LoParkour.log("Fixing missing config node %s in %s".formatted(node, file.getName()));
+ locale.set(node, reference.get(node));
+ modified = true;
+ }
+ }
+ if (modified) {
+ try { locale.save(file); }
+ catch (IOException ex) {
+ LoParkour.getPlugin().getLogger().severe(
+ "Error saving fixed locale %s — delete and restart — %s".formatted(file.getName(), ex.getMessage()));
+ }
+ }
+ LoParkour.log("Validated locale " + file.getName());
+ }
- /**
- * Returns an item from a provided json locale file with possible replacements.
- *
- * @param locale The locale
- * @param path The path in the json file
- * @param replace The Strings that will replace any appearances of a String following the regex "%[a-z]"
- * @return a non-null {@link Item} instance built from the description in the locale file
- */
@NotNull
- public static Item getItem(String locale, String path, String... replace) {
- if (locales.isEmpty()) { // during reloading
- return new Item(Material.STONE, "");
+ private static List getChildren(@NotNull FileConfiguration config) {
+ ConfigurationSection section = config.getConfigurationSection("");
+ return section != null ? new ArrayList<>(section.getKeys(true)) : Collections.emptyList();
+ }
+
+ // ── cache access (was LocaleCache) ────────────────────────────────────────
+
+ @NotNull
+ private static T cachedValue(@NotNull String locale, @NotNull Function extractor, @NotNull T defaultValue) {
+ synchronized (locales) {
+ if (locales.isEmpty()) return defaultValue;
+ FileConfiguration config = locales.get(locale);
+ if (config == null) return defaultValue;
+ try {
+ T result = extractor.apply(config);
+ return result != null ? result : defaultValue;
+ } catch (Exception e) { return defaultValue; }
}
+ }
- FileConfiguration base = locales.get(locale);
+ // ── item builder ──────────────────────────────────────────────────────────
- String material = base.getString("%s.material".formatted(path));
- String name = base.getString("%s.name".formatted(path));
- String lore = base.getString("%s.lore".formatted(path));
- int modelId = base.getInt("%s.model_id".formatted(path), -1);
+ @NotNull
+ private static Item buildItem(@NotNull FileConfiguration config, @NotNull String path, String... replace) {
+ String material = config.getString("%s.material".formatted(path), "STONE");
+ String name = applyReplacements(config.getString("%s.name".formatted(path), ""), replace);
+ String lore = applyReplacements(config.getString("%s.lore".formatted(path), ""), replace);
- if (material == null) {
- material = "";
- }
- if (name == null) {
- name = "";
- }
- if (lore == null) {
- lore = "";
- }
-
- int idx = 0;
- Matcher matcher = pattern.matcher(name);
- while (matcher.find()) {
- if (idx == replace.length) {
- break;
- }
-
- name = name.replaceFirst(matcher.group(), replace[idx]);
- idx++;
- }
-
- matcher = pattern.matcher(lore);
-
- while (matcher.find()) {
- if (idx == replace.length) {
- break;
- }
-
- lore = lore.replaceFirst(matcher.group(), replace[idx]);
- idx++;
- }
-
- Material mat = Material.getMaterial(material.toUpperCase());
+ Material mat = Materials.parse(material);
if (mat == null) {
- LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE as fallback".formatted(material, path));
+ LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE".formatted(material, path));
mat = Material.STONE;
}
Item item = new Item(mat, name);
-
- if (!lore.isEmpty()) {
- item.lore(lore.split("\\|\\|"));
- }
-
- if (modelId != -1) {
- // TODO: item.setCustomModelData(modelId);
- }
-
+ if (!lore.isEmpty()) item.lore(lore.split("\\|\\|"));
return item;
}
- public static List getChildren(FileConfiguration file, String path, boolean deep) {
- ConfigurationSection section = file.getConfigurationSection(path);
+ @NotNull
+ private static String applyReplacements(@NotNull String text, String... replacements) {
+ if (replacements.length == 0) return text;
+ String result = text;
+ int idx = 0;
+ Matcher matcher = REPLACEMENT_PATTERN.matcher(result);
+ while (matcher.find() && idx < replacements.length) {
+ result = result.replaceFirst(matcher.group(), replacements[idx++]);
+ }
+ return result;
+ }
- return section != null ? new ArrayList<>(section.getKeys(deep)) : Collections.emptyList();
+ @NotNull
+ private static String getPlayerLocale(@NotNull Player player) {
+ ParkourUser user = ParkourUser.getUser(player);
+ String loc = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
+ return (loc != null && !loc.isBlank()) ? loc : "en";
}
}
diff --git a/src/main/java/dev/loki/loparkour/config/Option.java b/src/main/java/dev/loki/loparkour/config/Option.java
index c26321a..34344a0 100644
--- a/src/main/java/dev/loki/loparkour/config/Option.java
+++ b/src/main/java/dev/loki/loparkour/config/Option.java
@@ -1,6 +1,7 @@
package dev.loki.loparkour.config;
import dev.loki.loparkour.LoParkour;
+import dev.loki.loparkour.util.Materials;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.style.RandomStyle;
@@ -9,24 +10,12 @@ import dev.loki.loparkour.util.ParticleData;
import org.bukkit.*;
import org.bukkit.block.BlockFace;
import org.bukkit.configuration.file.FileConfiguration;
-import org.bukkit.util.Vector;
import java.util.*;
import java.util.function.BiFunction;
/**
- * Top-level config constants used throughout the plugin.
- *
- * Heavy sub-sections are delegated to focused classes:
- *
- * - {@link SqlOptions} – SQL connection settings
- * - {@link ParticleOptions} – particle & sound settings
- * - {@link GenerationOptions} – generation & jump-validation settings
- *
- *
- * Those classes expose their own static fields. Option re-exports the most
- * commonly accessed ones via forwarding fields so existing call-sites compile
- * without changes.
+ * All config-derived constants used at runtime.
*/
public class Option {
@@ -41,9 +30,8 @@ public class Option {
public static Location GO_BACK_LOC;
- // ── Forwarded from sub-classes (backwards compat) ─────────────────────────
+ // ── SQL ───────────────────────────────────────────────────────────────────
- /** @see SqlOptions#SQL */
public static boolean SQL;
public static String SQL_URL;
public static int SQL_PORT;
@@ -52,38 +40,49 @@ public class Option {
public static String SQL_PASSWORD;
public static String SQL_PREFIX;
- /** @see ParticleOptions#SHAPE */
- public static ParticleShape PARTICLE_SHAPE;
- /** @see ParticleOptions#SOUND_TYPE */
- public static Sound SOUND_TYPE;
- /** @see ParticleOptions#SOUND_PITCH */
- public static int SOUND_PITCH;
- /** @see ParticleOptions#SOUND_VOLUME */
- public static int SOUND_VOLUME;
- /** @see ParticleOptions#PARTICLE_TYPE */
- public static Particle PARTICLE_TYPE;
- /** @see ParticleOptions#PARTICLE_DATA */
+ // ── Particles / Sound ─────────────────────────────────────────────────────
+
+ public static ParticleShape PARTICLE_SHAPE;
+ public static Sound SOUND_TYPE;
+ public static int SOUND_PITCH;
+ public static int SOUND_VOLUME;
+ public static Particle PARTICLE_TYPE;
public static ParticleData> PARTICLE_DATA;
- /** @see GenerationOptions */
+ // ── Generation ────────────────────────────────────────────────────────────
+
public static double TYPE_NORMAL, TYPE_SPECIAL, TYPE_SCHEMATICS;
public static double SPECIAL_ICE, SPECIAL_SLAB, SPECIAL_PANE, SPECIAL_FENCE;
public static double NORMAL_DISTANCE_1, NORMAL_DISTANCE_2, NORMAL_DISTANCE_3, NORMAL_DISTANCE_4;
public static double NORMAL_HEIGHT_1, NORMAL_HEIGHT_0, NORMAL_HEIGHT_NEG1, NORMAL_HEIGHT_NEG2;
public static int MAX_Y, MIN_Y;
public static int BLOCK_CLEANUP_DISTANCE, CLEANUP_INTERVAL;
+ /** Block indices to keep behind the rearmost player before trail air-cleanup. */
+ public static int TRAIL_KEEP_BEHIND;
public static boolean GHOST_MODE_ENABLED;
public static int GHOST_SHOW_TOP;
public static double GHOST_TRANSPARENCY;
+ // ── Jump validation ───────────────────────────────────────────────────────
+
+ public static boolean JUMP_VALIDATION_ENABLED;
+ public static double MAX_JUMP_DISTANCE;
+ public static double MAX_HORIZONTAL_DISTANCE;
+ public static double MAX_VERTICAL_UP;
+ public static double MAX_VERTICAL_DOWN;
+
+ // ── Jump types ────────────────────────────────────────────────────────────
+
+ public static boolean JUMP_TYPES_ENABLED;
+ public static Map JUMP_TYPE_ENABLED;
+ public static Map JUMP_TYPE_CHANCE;
+
// ── init ──────────────────────────────────────────────────────────────────
public static void init(boolean firstLoad) {
- SqlOptions.init();
- ParticleOptions.init();
- GenerationOptions.init();
-
- syncForwardedFields();
+ initSql();
+ initParticles();
+ initGeneration();
initGeneral(firstLoad);
initOptions();
@@ -91,60 +90,139 @@ public class Option {
.forEach(Registry::register);
}
- /** Copy sub-class fields to this class so existing code still compiles. */
- private static void syncForwardedFields() {
- SQL = SqlOptions.SQL;
- SQL_URL = SqlOptions.URL;
- SQL_PORT = SqlOptions.PORT;
- SQL_DB = SqlOptions.DB;
- SQL_USERNAME = SqlOptions.USERNAME;
- SQL_PASSWORD = SqlOptions.PASSWORD;
- SQL_PREFIX = SqlOptions.PREFIX;
+ // ── SQL ───────────────────────────────────────────────────────────────────
- PARTICLE_SHAPE = ParticleOptions.SHAPE;
- SOUND_TYPE = ParticleOptions.SOUND_TYPE;
- SOUND_PITCH = ParticleOptions.SOUND_PITCH;
- SOUND_VOLUME = ParticleOptions.SOUND_VOLUME;
- PARTICLE_TYPE = ParticleOptions.PARTICLE_TYPE;
- PARTICLE_DATA = ParticleOptions.PARTICLE_DATA;
-
- TYPE_NORMAL = GenerationOptions.TYPE_NORMAL;
- TYPE_SPECIAL = GenerationOptions.TYPE_SPECIAL;
- TYPE_SCHEMATICS = GenerationOptions.TYPE_SCHEMATICS;
-
- SPECIAL_ICE = GenerationOptions.SPECIAL_ICE;
- SPECIAL_SLAB = GenerationOptions.SPECIAL_SLAB;
- SPECIAL_PANE = GenerationOptions.SPECIAL_PANE;
- SPECIAL_FENCE = GenerationOptions.SPECIAL_FENCE;
-
- NORMAL_DISTANCE_1 = GenerationOptions.NORMAL_DISTANCE_1;
- NORMAL_DISTANCE_2 = GenerationOptions.NORMAL_DISTANCE_2;
- NORMAL_DISTANCE_3 = GenerationOptions.NORMAL_DISTANCE_3;
- NORMAL_DISTANCE_4 = GenerationOptions.NORMAL_DISTANCE_4;
-
- NORMAL_HEIGHT_1 = GenerationOptions.NORMAL_HEIGHT_1;
- NORMAL_HEIGHT_0 = GenerationOptions.NORMAL_HEIGHT_0;
- NORMAL_HEIGHT_NEG1 = GenerationOptions.NORMAL_HEIGHT_NEG1;
- NORMAL_HEIGHT_NEG2 = GenerationOptions.NORMAL_HEIGHT_NEG2;
-
- MAX_Y = GenerationOptions.MAX_Y;
- MIN_Y = GenerationOptions.MIN_Y;
-
- BLOCK_CLEANUP_DISTANCE = GenerationOptions.BLOCK_CLEANUP_DISTANCE;
- CLEANUP_INTERVAL = GenerationOptions.CLEANUP_INTERVAL;
-
- GHOST_MODE_ENABLED = GenerationOptions.GHOST_MODE_ENABLED;
- GHOST_SHOW_TOP = GenerationOptions.GHOST_SHOW_TOP;
- GHOST_TRANSPARENCY = GenerationOptions.GHOST_TRANSPARENCY;
+ private static void initSql() {
+ SQL = Config.CONFIG.getBoolean("sql.enabled");
+ SQL_PORT = Config.CONFIG.getInt("sql.port");
+ SQL_DB = Config.CONFIG.getString("sql.database");
+ SQL_URL = Config.CONFIG.getString("sql.url");
+ SQL_USERNAME = Config.CONFIG.getString("sql.username");
+ String envPassword = System.getenv("LOPARKOUR_SQL_PASSWORD");
+ SQL_PASSWORD = (envPassword != null && !envPassword.isEmpty())
+ ? envPassword
+ : Config.CONFIG.getString("sql.password");
+ SQL_PREFIX = Config.CONFIG.getString("sql.prefix");
}
- // ── General init ──────────────────────────────────────────────────────────
+ // ── Particles / Sound ─────────────────────────────────────────────────────
+
+ private static void initParticles() {
+ SOUND_TYPE = resolveSound(Config.CONFIG.getString("particles.sound-type"));
+ PARTICLE_TYPE = resolveParticle(Config.CONFIG.getString("particles.particle-type"));
+ PARTICLE_SHAPE = resolveShape(Config.CONFIG.getString("particles.particle-shape"));
+
+ SOUND_PITCH = Config.CONFIG.getInt("particles.sound-pitch");
+ SOUND_VOLUME = Config.CONFIG.getInt("particles.sound-volume");
+ PARTICLE_DATA = new ParticleData<>(PARTICLE_TYPE, null, 10);
+ }
+
+ private static Sound resolveSound(String name) {
+ if (name != null && !name.isEmpty()) {
+ try { return Sound.valueOf(name.toUpperCase()); }
+ catch (IllegalArgumentException ignored) {}
+ }
+ for (String fb : new String[]{"BLOCK_NOTE_BLOCK_GUITAR", "BLOCK_NOTE_BLOCK_PLING", "BLOCK_NOTE_PLING"}) {
+ try { return Sound.valueOf(fb); }
+ catch (IllegalArgumentException ignored) {}
+ }
+ LoParkour.getPlugin().getLogger().warning("Could not resolve any sound, using first available.");
+ return Sound.values()[0];
+ }
+
+ private static Particle resolveParticle(String name) {
+ if (name != null && !name.isEmpty()) {
+ try { return Particle.valueOf(name.toUpperCase()); }
+ catch (IllegalArgumentException ignored) {}
+ }
+ for (String fb : new String[]{"INSTANT_EFFECT", "SPELL_INSTANT", "CRIT"}) {
+ try { return Particle.valueOf(fb); }
+ catch (IllegalArgumentException ignored) {}
+ }
+ LoParkour.getPlugin().getLogger().warning("Could not resolve any particle, using first available.");
+ return Particle.values()[0];
+ }
+
+ private static ParticleShape resolveShape(String name) {
+ if (name != null && !name.isEmpty()) {
+ try { return ParticleShape.valueOf(name.toUpperCase()); }
+ catch (IllegalArgumentException ignored) {}
+ }
+ return ParticleShape.BOX;
+ }
+
+ // ── Generation ────────────────────────────────────────────────────────────
+
+ private static void initGeneration() {
+ JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled");
+ MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0;
+ MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0;
+ MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0;
+ MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0;
+
+ JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled");
+ JUMP_TYPE_ENABLED = new HashMap<>();
+ JUMP_TYPE_CHANCE = new HashMap<>();
+ if (Config.CONFIG.isPath("jump-types.types")) {
+ for (String type : Config.CONFIG.getChildren("jump-types.types")) {
+ String p = "jump-types.types." + type;
+ JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(p + ".enabled") && Config.CONFIG.getBoolean(p + ".enabled"));
+ JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(p + ".chance") ? Config.CONFIG.getDouble(p + ".chance") : 0.0);
+ }
+ }
+
+ BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100;
+ CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100;
+ TRAIL_KEEP_BEHIND = Config.CONFIG.isPath("memory.trail-keep-behind") ? Config.CONFIG.getInt("memory.trail-keep-behind") : 10;
+
+ GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled");
+ GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3;
+ GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5;
+
+ TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0;
+ TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0;
+ TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0;
+
+ SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0;
+ SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0;
+ SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0;
+ SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0;
+
+ NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0;
+ NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0;
+ NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0;
+ NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0;
+
+ NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0;
+ NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0;
+ NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0;
+ NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0;
+
+ MAX_Y = Config.GENERATION.getInt("generation.settings.max-y");
+ MIN_Y = Config.GENERATION.getInt("generation.settings.min-y");
+
+ if (MIN_Y >= MAX_Y) {
+ MIN_Y = 100; MAX_Y = 200;
+ LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200.");
+ }
+ }
+
+ // ── General ───────────────────────────────────────────────────────────────
private static void initGeneral(boolean firstLoad) {
GO_BACK_LOC = parseLocation(Config.CONFIG.getString("bungeecord.go-back"));
- String[] axes = Config.CONFIG.getString("bungeecord.go-back-axes").split(",");
- GO_BACK_LOC.setPitch(Float.parseFloat(axes[0]));
- GO_BACK_LOC.setYaw(Float.parseFloat(axes[1]));
+ try {
+ String axesRaw = Config.CONFIG.getString("bungeecord.go-back-axes");
+ if (!axesRaw.isEmpty()) {
+ String[] axes = axesRaw.split(",");
+ if (axes.length >= 2) {
+ GO_BACK_LOC.setPitch(Float.parseFloat(axes[0].trim()));
+ GO_BACK_LOC.setYaw(Float.parseFloat(axes[1].trim()));
+ }
+ }
+ } catch (Exception ex) {
+ LoParkour.getPlugin().getLogger().warning("Invalid bungeecord.go-back-axes, using 0,0");
+ }
POSSIBLE_LEADS = Config.CONFIG.getIntList("options.leads.amount");
for (int lead : new ArrayList<>(POSSIBLE_LEADS)) {
@@ -193,20 +271,31 @@ public class Option {
// ── Helpers ───────────────────────────────────────────────────────────────
private static Location parseLocation(String location) {
- String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").split(" ");
- World world = Bukkit.getWorld(values[3]);
- if (world == null) world = Bukkit.getWorlds().get(0);
- return new Location(world, Double.parseDouble(values[0]),
- Double.parseDouble(values[1]), Double.parseDouble(values[2]));
+ try {
+ String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").trim().split("\\s+");
+ if (values.length < 4) {
+ throw new IllegalArgumentException("Expected 4 values (x,y,z,world), got " + values.length);
+ }
+ World world = Bukkit.getWorld(values[3]);
+ if (world == null) world = Bukkit.getWorlds().get(0);
+ return new Location(world, Double.parseDouble(values[0]),
+ Double.parseDouble(values[1]), Double.parseDouble(values[2]));
+ } catch (Exception ex) {
+ LoParkour.getPlugin().getLogger().warning(
+ "Invalid bungeecord.go-back value '" + location + "'. Falling back to spawn world origin.");
+ World fallback = Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0);
+ return new Location(fallback, 0, 100, 0);
+ }
}
public static Set