Update dependencies and improve configuration management

- Updated LoLib dependency to version 3.0.0 in build.gradle.kts
- Added HikariCP and MySQL connector dependencies for improved database handling
- Enhanced jar minimization process to exclude JDBC pool and driver
- Refactored configuration management by introducing ConfigAccessor and ConfigLoader for better file handling and validation
- Removed deprecated GenerationOptions class and integrated its functionality into the new structure
- Improved command error handling and added cooldown hints for admin commands
This commit is contained in:
loki 2026-04-01 16:21:09 +02:00
parent 978f739045
commit 3100c53229
86 changed files with 4324 additions and 3248 deletions

View file

@ -18,9 +18,9 @@ import dev.loki.loparkour.player.UserRegistry;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.Divider;
import dev.loki.loparkour.world.World;
import dev.lolib.core.LoPlugin;
import dev.lolib.scheduler.Scheduler;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.bstats.bukkit.Metrics;
@ -30,6 +30,7 @@ import org.bstats.charts.SingleLineChart;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.ArrayList;
/**
* Main class of LoParkour
@ -171,7 +172,7 @@ public final class LoParkour extends LoPlugin {
Metrics metrics = new Metrics(this, 29754);
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.locales.size())));
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.getLocaleCount())));
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
int joins = UserRegistry.getJoinCount();
return joins;
@ -181,12 +182,30 @@ public final class LoParkour extends LoPlugin {
@Override
public void disable() {
try {
// Clear all sessions BEFORE leaving players to prevent memory leaks
new ArrayList<>(Divider.sections.keySet()).forEach(session -> {
try {
session.onAllPlayersLeft();
} catch (Exception e) {
getLogger().warning("Error cleaning up session: " + e.getMessage());
}
});
// Remove all sessions from Divider
new ArrayList<>(Divider.sections.keySet()).forEach(Divider::remove);
// Now leave all players
for (ParkourUser user : ParkourUser.getUsers()) {
ParkourUser.leave(user);
}
// write all LoParkour gamemodes
Modes.DEFAULT.getLeaderboard().write(false);
// write all LoParkour gamemodes (may be null if enable failed)
if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) {
Modes.DEFAULT.getLeaderboard().write(false);
}
// Clear rewards to prevent memory leaks
dev.loki.loparkour.reward.Rewards.clear();
Storage.close();
World.delete();

View file

@ -16,6 +16,7 @@ import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
@ -28,13 +29,13 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
/**
* Main command router for /LoParkour and /parkour.
* Delegates to {@link PlayerCommandHandler}, {@link AdminCommandHandler},
* and {@link SchematicCommandHandler}.
*/
@SuppressWarnings("deprecation")
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private final PlayerCommandHandler player = new PlayerCommandHandler();
@ -43,15 +44,23 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
Player p = sender instanceof Player ? (Player) sender : null;
try {
Player p = sender instanceof Player ? (Player) sender : null;
switch (args.length) {
case 0 -> player.handleNoArgs(sender, p);
case 1 -> player.handle(args[0], sender, p);
case 2 -> handle2(args[0], args[1], sender, p);
case 3 -> handle3(args[0], args[1], args[2], sender, p);
switch (args.length) {
case 0 -> player.handleNoArgs(sender, p);
case 1 -> player.handle(args[0], sender, p);
case 2 -> handle2(args[0], args[1], sender, p);
case 3 -> handle3(args[0], args[1], args[2], sender, p);
default -> sender.sendMessage(ChatColor.GRAY + "Too many arguments. Try /parkour help");
}
return true;
} catch (Throwable t) {
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
sender.sendMessage(ChatColor.RED + "LoParkour: command error — see server console.");
return true;
}
return true;
}
// ── 2-arg routing ──────────────────────────────────────────────────────────
@ -108,7 +117,12 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
if (user != null && user.session == session) return;
if (session.isAcceptingPlayers()) {
((MultiMode) session.generator.getMode()).join(p, session);
Mode sessionMode = session.generator.getMode();
if (sessionMode instanceof MultiMode mm) {
mm.join(p, session);
} else {
Modes.SPECTATOR.create(p, session);
}
} else {
Modes.SPECTATOR.create(p, session);
}
@ -153,7 +167,10 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
completions.add("everyone");
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if (a1.equals("join") && ParkourOption.JOIN.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if (a1.equals("leaderboard") && ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
} else if (a1.equals("schematic") && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload"));
} else if ((a1.equals("forcejoin") || a1.equals("forceleave")) && sender.hasPermission(ParkourOption.ADMIN.permission)) {

View file

@ -17,7 +17,6 @@ import org.jetbrains.annotations.Nullable;
* <li>For Schematics, please view {@link dev.efnilite.LoParkour.schematic.LPSchematic}</li>
* </ul>
*/
@SuppressWarnings("unused")
public class ParkourAPI {
private ParkourAPI() throws IllegalAccessException {

View file

@ -1,6 +1,5 @@
package dev.loki.loparkour.api;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.style.Style;
import org.jetbrains.annotations.NotNull;

View file

@ -17,11 +17,11 @@ public class ParkourSchematicGenerateEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
public final LPSchematic schematic;
public final ParkourGenerator generator;
public final ParkourPlayer player;
public ParkourSchematicGenerateEvent(dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
public ParkourSchematicGenerateEvent(LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
this.schematic = schematic;
this.generator = generator;
this.player = player;

View file

@ -2,8 +2,6 @@ package dev.loki.loparkour.command;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
@ -28,6 +26,8 @@ import java.util.UUID;
*/
public class AdminCommandHandler {
private static final String COOLDOWN_HINT = "<gray>Please wait before using this again.";
private final PlayerCommandHandler base;
public AdminCommandHandler(PlayerCommandHandler base) {
@ -56,6 +56,8 @@ public class AdminCommandHandler {
// ── forcejoin ──────────────────────────────────────────────────────────────
private void handleForceJoin(String target, CommandSender sender) {
if (!base.cooldown(sender, "forcejoin", 2500, COOLDOWN_HINT)) return;
if (target.equalsIgnoreCase("everyone")) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
send(sender, LoParkour.PREFIX + "Force joined everyone!");
@ -76,6 +78,8 @@ public class AdminCommandHandler {
// ── forceleave ─────────────────────────────────────────────────────────────
private void handleForceLeave(String target, CommandSender sender) {
if (!base.cooldown(sender, "forceleave", 2500, COOLDOWN_HINT)) return;
if (target.equalsIgnoreCase("everyone")) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
send(sender, LoParkour.PREFIX + "Force kicked everyone!");
@ -91,7 +95,7 @@ public class AdminCommandHandler {
// ── reset ──────────────────────────────────────────────────────────────────
private void handleReset(String target, CommandSender sender) {
if (!base.cooldown(sender, "reset", 2500)) return;
if (!base.cooldown(sender, "reset", 2500, COOLDOWN_HINT)) return;
if (target.equalsIgnoreCase("everyone")) {
Registry.getModes().stream()
@ -116,7 +120,7 @@ public class AdminCommandHandler {
// ── recoverinventory ───────────────────────────────────────────────────────
private void handleRecoverInventory(String target, CommandSender sender) {
if (!base.cooldown(sender, "recoverinventory", 2500)) return;
if (!base.cooldown(sender, "recoverinventory", 2500, COOLDOWN_HINT)) return;
Player other = Bukkit.getPlayer(target);
if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; }
@ -142,10 +146,17 @@ public class AdminCommandHandler {
.orElse(null);
}
@SuppressWarnings("deprecation") // Bukkit: name-based OfflinePlayer lookup (admin-only)
private UUID resolveUUID(String input) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) return online.getUniqueId();
if (input.contains("-")) return UUID.fromString(input);
if (input.contains("-")) {
try {
return UUID.fromString(input);
} catch (IllegalArgumentException ignored) {
// fall through to offline lookup by name
}
}
return Bukkit.getOfflinePlayer(input).getUniqueId();
}

View file

@ -60,7 +60,13 @@ public class PlayerCommandHandler {
case "menu", "main" -> {
if (ParkourOption.MAIN.mayPerform(player)) Menus.MAIN.open(player);
}
case "leaderboard" -> player.performCommand("LoParkour leaderboard invalid");
case "leaderboard" -> {
if (!ParkourOption.LEADERBOARDS.mayPerform(player)) {
send(sender, Locales.getString(player, "other.no_do"));
return;
}
Menus.LEADERBOARDS.open(player);
}
case "schematic" -> {
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
send(sender, Locales.getString(player, "other.no_do"));
@ -107,10 +113,22 @@ public class PlayerCommandHandler {
}
public boolean cooldown(CommandSender sender, String key, long millis) {
return cooldown(sender, key, millis, null);
}
/**
* @param whenBlocked message sent (MiniMessage color) if still on cooldown; may be null for silent block
*/
public boolean cooldown(CommandSender sender, String key, long millis, @Nullable String whenBlocked) {
String fullKey = sender.getName() + ":" + key;
long now = System.currentTimeMillis();
Long last = cooldowns.get(fullKey);
if (last != null && now - last < millis) return false;
if (last != null && now - last < millis) {
if (whenBlocked != null) {
send(sender, whenBlocked);
}
return false;
}
cooldowns.put(fullKey, now);
return true;
}

View file

@ -1,22 +1,15 @@
package dev.loki.loparkour.config;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.schematic.Schematics;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
/**
* Config management class.
* Refactored Config enum using composition pattern.
* Delegates functionality to specialized components.
*/
public enum Config {
@ -25,252 +18,102 @@ public enum Config {
REWARDS("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")),
SCHEMATICS("schematics/schematics.yml", List.of("difficulty"));
/**
* The path to this file, incl. plugin folder.
* Not final — resolved lazily on first reload() call after plugin instance is set.
*/
public File path;
/**
* The name of this file, e.g. config.yml
*/
public final String fileName;
/**
* The sections in the file that will be ignored when updating the keys.
*/
public final List<String> ignoredSections;
/**
* The {@link FileConfiguration} instance associated with this config file.
*/
public FileConfiguration fileConfiguration;
private ConfigAccessor accessor;
Config(String fileName, @Nullable List<String> ignoredSections) {
this.fileName = fileName;
this.ignoredSections = ignoredSections;
// path resolved lazily via getPath() — do NOT call LoParkour.getInFolder() here,
// because enum constants are initialized before onEnable() sets the instance.
this.path = null; // placeholder; real path resolved on first access
this.path = null; // Resolved lazily
}
/**
* Reloads all config files.
* On first call (initialLoad=true) also resolves file paths and saves defaults.
* Reload all configuration files.
*/
public static void reload(boolean initialLoad) {
ConfigLoader.reloadAllConfigs(initialLoad);
// Update accessors for all configs
for (Config config : values()) {
if (initialLoad) {
// Resolve path now that instance is guaranteed to be set
config.path = LoParkour.getInFolder(config.fileName);
if (!config.path.exists()) {
LoParkour.getPlugin().saveResource(config.fileName, false);
}
config.update();
if (config.fileConfiguration != null) {
config.accessor = new ConfigAccessor(config.fileConfiguration, config.fileName);
}
config.load();
}
// Validate configuration values
validateConfigs();
// read config stuff
Rewards.init();
Locales.init();
Schematics.init();
Option.init(initialLoad);
// Now that accessors exist, initialize systems that depend on Config getters
ConfigLoader.initializeDependentSystems(initialLoad);
}
/**
* Validates critical configuration values to prevent crashes and unexpected behavior.
*/
private static void validateConfigs() {
var logger = LoParkour.getPlugin().getLogger();
// Validate border-size
if (CONFIG.isPath("world.border-size")) {
int borderSize = CONFIG.getInt("world.border-size");
if (borderSize <= 0) {
logger.severe("Invalid config: world.border-size must be > 0, found: " + borderSize);
logger.severe("Using default value: 1000");
}
}
// Validate world dimensions
if (CONFIG.isPath("world.max-y") && CONFIG.isPath("world.min-y")) {
int maxY = CONFIG.getInt("world.max-y");
int minY = CONFIG.getInt("world.min-y");
if (maxY <= minY) {
logger.severe("Invalid config: world.max-y (" + maxY + ") must be > world.min-y (" + minY + ")");
logger.severe("Using default values: max-y=320, min-y=-64");
}
}
// Validate section dimensions
if (CONFIG.isPath("world.section.width") && CONFIG.isPath("world.section.length")) {
int width = CONFIG.getInt("world.section.width");
int length = CONFIG.getInt("world.section.length");
if (width <= 0 || length <= 0) {
logger.severe("Invalid config: world.section dimensions must be > 0, found: width=" + width + ", length=" + length);
logger.severe("Using default values: width=50, length=50");
}
}
// Validate generation chances (must sum to reasonable values)
if (GENERATION.isPath("chances.height")) {
double sum = GENERATION.getDouble("chances.height.1") +
GENERATION.getDouble("chances.height.0") +
GENERATION.getDouble("chances.height.-1") +
GENERATION.getDouble("chances.height.-2");
if (sum <= 0) {
logger.severe("Invalid config: generation height chances sum to " + sum + ", must be > 0");
logger.severe("Check generation.yml chances.height section");
}
}
// Validate ghost mode settings
if (CONFIG.isPath("ghost-mode.show-top")) {
int showTop = CONFIG.getInt("ghost-mode.show-top");
if (showTop < 0 || showTop > 10) {
logger.warning("Config: ghost-mode.show-top should be between 0-10, found: " + showTop);
}
}
// Validate cleanup settings
if (GENERATION.isPath("advanced.cleanup-distance")) {
int cleanupDistance = GENERATION.getInt("advanced.cleanup-distance");
if (cleanupDistance <= 0) {
logger.severe("Invalid config: advanced.cleanup-distance must be > 0, found: " + cleanupDistance);
logger.severe("Using default value: 100");
}
}
}
/**
* Loads the file from disk.
* Load this configuration file from disk.
*/
public void load() {
if (path == null) return; // not yet initialized, skip
this.fileConfiguration = YamlConfiguration.loadConfiguration(path);
if (path == null) return;
this.fileConfiguration = ConfigLoader.loadConfig(path);
this.accessor = new ConfigAccessor(fileConfiguration, fileName);
}
/**
* Updates the file so all keys are present.
* Any key that exists in the bundled default but is missing on disk
* will be appended to the correct section automatically.
* Update this configuration file with new keys.
*/
public void update() {
try {
ConfigUpdater.update(LoParkour.getPlugin(), fileName, path, ignoredSections);
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to update config file: " + fileName, ex);
if (path != null) {
ConfigLoader.updateConfig(fileName, path, ignoredSections);
}
}
/**
* @param path The path.
* @return True when path exists, false if not.
*/
// Delegate all accessor methods to ConfigAccessor
public boolean isPath(@NotNull String path) {
return fileConfiguration.isSet(path);
return accessor != null && accessor.isPath(path);
}
/**
* @param path The path.
* @return The value at path.
*/
public Object get(@NotNull String path) {
check(path);
return fileConfiguration.get(path);
return accessor.get(path);
}
/**
* @param path The path.
* @return The boolean value at path.
*/
public boolean getBoolean(@NotNull String path) {
check(path);
return fileConfiguration.getBoolean(path);
return accessor.getBoolean(path);
}
public boolean getBoolean(@NotNull String path, boolean defaultValue) {
return accessor != null ? accessor.getBoolean(path, defaultValue) : defaultValue;
}
/**
* @param path The path.
* @return The int value at path.
*/
public int getInt(@NotNull String path) {
check(path);
return fileConfiguration.getInt(path);
return accessor.getInt(path);
}
public int getInt(@NotNull String path, int defaultValue) {
return accessor != null ? accessor.getInt(path, defaultValue) : defaultValue;
}
/**
* @param path The path.
* @return The double value at path.
*/
public double getDouble(@NotNull String path) {
check(path);
return fileConfiguration.getDouble(path);
return accessor.getDouble(path);
}
/**
* @param path The path.
* @return The String value at path.
*/
@NotNull
public String getString(@NotNull String path) {
check(path);
return fileConfiguration.getString(path, "");
return accessor.getString(path);
}
/**
* @param path The path.
* @return The String list value at path.
*/
@NotNull
public List<String> getStringList(@NotNull String path) {
check(path);
return fileConfiguration.getStringList(path);
return accessor.getStringList(path);
}
/**
* @param path The path.
* @return The int list value at path.
*/
@NotNull
public List<Integer> getIntList(@NotNull String path) {
check(path);
return fileConfiguration.getIntegerList(path);
return accessor.getIntList(path);
}
/**
* @param path The path.
* @param deep Whether search should include children of children as well.
* @return The children nodes from path.
*/
@NotNull
public List<String> getChildren(@NotNull String path, boolean... deep) {
check(path);
ConfigurationSection section = fileConfiguration.getConfigurationSection(path);
if (section == null) {
return new ArrayList<>();
}
boolean isDeep = deep.length > 0 && deep[0];
return new ArrayList<>(section.getKeys(isDeep));
return accessor.getChildren(path, deep);
}
// checks if the specified path exists to avoid developer error
private void check(@NotNull String path) {
if (!isPath(path)) {
throw new NoSuchElementException("Unknown path %s in %s".formatted(path, fileName));
}
}
}
}

View file

@ -0,0 +1,141 @@
package dev.loki.loparkour.config;
import dev.loki.loparkour.LoParkour;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Provides safe access to configuration values with validation.
*/
public class ConfigAccessor {
private final FileConfiguration config;
private final String fileName;
private final Set<String> warnedMissingPaths = ConcurrentHashMap.newKeySet();
public ConfigAccessor(@NotNull FileConfiguration config, @NotNull String fileName) {
this.config = config;
this.fileName = fileName;
}
/**
* Check if path exists in configuration.
*/
public boolean isPath(@NotNull String path) {
return config.isSet(path);
}
/**
* Get raw object value at path.
*/
public Object get(@NotNull String path) {
warnIfMissing(path);
return config.get(path);
}
/**
* Get boolean value at path.
*/
public boolean getBoolean(@NotNull String path) {
warnIfMissing(path);
return config.getBoolean(path, false);
}
/**
* Boolean at path, or default when the path is absent (no missing-path warning).
*/
public boolean getBoolean(@NotNull String path, boolean defaultValue) {
if (!isPath(path)) {
return defaultValue;
}
return config.getBoolean(path, defaultValue);
}
/**
* Get integer value at path.
*/
public int getInt(@NotNull String path) {
warnIfMissing(path);
return config.getInt(path, 0);
}
/**
* Get integer value at path with default.
*/
public int getInt(@NotNull String path, int defaultValue) {
if (!isPath(path)) {
return defaultValue;
}
return config.getInt(path, defaultValue);
}
/**
* Get double value at path.
*/
public double getDouble(@NotNull String path) {
warnIfMissing(path);
return config.getDouble(path, 0.0);
}
/**
* Get string value at path.
*/
@NotNull
public String getString(@NotNull String path) {
warnIfMissing(path);
return config.getString(path, "");
}
/**
* Get string list at path.
*/
@NotNull
public List<String> getStringList(@NotNull String path) {
warnIfMissing(path);
return config.getStringList(path);
}
/**
* Get integer list at path.
*/
@NotNull
public List<Integer> getIntList(@NotNull String path) {
warnIfMissing(path);
return config.getIntegerList(path);
}
/**
* Get child keys from configuration section.
*/
@NotNull
public List<String> getChildren(@NotNull String path, boolean... deep) {
if (!isPath(path)) {
warnIfMissing(path);
return new ArrayList<>();
}
ConfigurationSection section = config.getConfigurationSection(path);
if (section == null) {
return new ArrayList<>();
}
boolean isDeep = deep.length > 0 && deep[0];
return new ArrayList<>(section.getKeys(isDeep));
}
private void warnIfMissing(@NotNull String path) {
if (isPath(path)) {
return;
}
if (warnedMissingPaths.add(path)) {
LoParkour.getPlugin().getLogger().warning(
"Missing config path '" + path + "' in " + fileName + ". Using a safe default value.");
}
}
}

View file

@ -0,0 +1,87 @@
package dev.loki.loparkour.config;
import dev.loki.loparkour.LoParkour;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.List;
import java.util.logging.Logger;
/**
* Handles loading, updating, and basic validation of configuration files.
*/
public class ConfigLoader {
@NotNull
public static FileConfiguration loadConfig(@NotNull File configFile) {
return YamlConfiguration.loadConfiguration(configFile);
}
@NotNull
public static File initializeConfigFile(@NotNull String fileName) {
File configFile = LoParkour.getInFolder(fileName);
if (!configFile.exists()) {
LoParkour.getPlugin().saveResource(fileName, false);
}
return configFile;
}
public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List<String> ignoredSections) {
try {
var plugin = LoParkour.getPlugin();
var resourceStream = plugin.getResource(fileName);
if (resourceStream != null) {
ConfigUpdater.update(configFile, resourceStream, "1.0", ignoredSections, plugin.getLogger());
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to update config file: " + fileName, ex);
}
}
public static void reloadAllConfigs(boolean initialLoad) {
for (Config config : Config.values()) {
if (initialLoad) {
config.path = initializeConfigFile(config.fileName);
updateConfig(config.fileName, config.path, config.ignoredSections);
}
config.fileConfiguration = loadConfig(config.path);
}
validateConfigs();
}
public static void initializeDependentSystems(boolean firstLoad) {
dev.loki.loparkour.reward.Rewards.init();
Locales.init();
dev.loki.loparkour.schematic.Schematics.init();
Option.init(firstLoad);
}
// ── inline validation (was ConfigValidatorRefactored) ─────────────────────
private static void validateConfigs() {
Logger log = LoParkour.getPlugin().getLogger();
if (Config.CONFIG.isPath("world.max-y") && Config.CONFIG.isPath("world.min-y")) {
int maxY = Config.CONFIG.getInt("world.max-y");
int minY = Config.CONFIG.getInt("world.min-y");
if (maxY <= minY) {
log.severe("Invalid config: world.max-y (%d) must be > world.min-y (%d)".formatted(maxY, minY));
}
}
if (Config.GENERATION.isPath("generation.normal.height")) {
double sum = Config.GENERATION.getDouble("generation.normal.height.1")
+ Config.GENERATION.getDouble("generation.normal.height.0")
+ Config.GENERATION.getDouble("generation.normal.height.-1")
+ Config.GENERATION.getDouble("generation.normal.height.-2");
if (sum <= 0) {
log.severe("Invalid config: generation height chances sum to %.1f, must be > 0".formatted(sum));
}
}
}
}

View file

@ -1,287 +1,197 @@
package dev.loki.loparkour.config;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Automatically adds missing keys from the default (bundled) config
* into the player's config file on disk, preserving all existing values,
* comments, and blank lines.
*
* <p>Algorithm:
* <ol>
* <li>Load the default config from the jar as a list of raw lines.</li>
* <li>Load the on-disk config as a flat key→value map.</li>
* <li>Walk the default lines; for every key that is missing on disk,
* emit the default line (with its comment header) into the output.</li>
* <li>Rewrite the on-disk file with the merged result.</li>
* </ol>
*
* Sections listed in {@code ignoredSections} are never touched — their
* default content is never injected (user manages them freely).
* Merges a resource-template YAML with the user's existing file,
* preserving user values while adding any new keys from the template.
* Uses full YAML paths (e.g. "sql.enabled") to avoid key collisions.
*/
public final class ConfigUpdater {
public class ConfigUpdater {
private ConfigUpdater() {}
private final Logger logger;
private ConfigUpdater(@NotNull Logger logger) {
this.logger = logger;
}
/**
* Updates {@code diskFile} by injecting any keys that exist in the
* bundled default but are absent on disk.
*
* @param plugin the plugin (used to read the bundled resource)
* @param resourceName path inside the jar, e.g. {@code "config.yml"}
* @param diskFile the on-disk file to update
* @param ignoredSections top-level sections whose keys should never be added
* (pass {@code null} or empty list to ignore nothing)
*/
public static void update(
@NotNull Plugin plugin,
@NotNull String resourceName,
@NotNull File diskFile,
@Nullable List<String> ignoredSections
) throws IOException {
@NotNull File configFile,
@NotNull InputStream templateStream,
@NotNull String expectedVersion,
@Nullable List<String> ignoredKeys,
@NotNull Logger logger) throws IOException {
List<String> defaultLines = readResource(plugin, resourceName);
if (defaultLines == null) {
plugin.getLogger().warning("[ConfigUpdater] Resource not found in jar: " + resourceName);
return;
}
new ConfigUpdater(logger).performUpdate(configFile, templateStream, ignoredKeys);
}
// Flat key set of what already exists on disk (dot-separated paths)
Set<String> diskKeys = flatKeys(diskFile);
// ── core ──────────────────────────────────────────────────────────────────
// Ignored section prefixes (e.g. "styles" → skip any path starting with "styles.")
Set<String> ignored = normalizedIgnored(ignoredSections);
private void performUpdate(
@NotNull File configFile,
@NotNull InputStream templateStream,
@Nullable List<String> ignoredKeys) throws IOException {
List<String> diskLines = readLines(diskFile);
List<String> outputLines = merge(defaultLines, diskLines, diskKeys, ignored);
// Only write if something actually changed
if (!outputLines.equals(diskLines)) {
writeLines(diskFile, outputLines);
plugin.getLogger().info("[ConfigUpdater] Updated " + resourceName
+ " — added " + countNew(diskLines, outputLines) + " new key(s).");
File backup = createBackup(configFile);
try {
List<String> 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.
*
* <p>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<String> merge(
List<String> defaultLines,
List<String> diskLines,
Set<String> diskKeys,
Set<String> ignored
) {
// We need to insert missing keys at the right position.
// Build a copy of disk lines we will extend.
List<String> result = new ArrayList<>(diskLines);
@NotNull
private List<String> merge(
@NotNull InputStream templateStream,
@NotNull File existingFile,
@Nullable List<String> 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<String> pathStack = new ArrayDeque<>();
List<String> pendingComments = new ArrayList<>();
List<String> templateLines = readLines(templateStream);
List<String> 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<String> ignored = normalizeIgnored(ignoredKeys);
Map<String, String> existingValues = extractFullPathValues(existingLines);
// Blank line → flush pending comments, keep as separator
if (trimmed.isBlank()) {
pendingComments.add(raw);
List<String> result = new ArrayList<>();
Deque<String> 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<String> 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<String> lines, Deque<String> pathStack, int depth) {
if (depth == 0) {
// Top-level key → append at end
return lines.size();
}
@NotNull
private Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
Map<String, String> values = new LinkedHashMap<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
// Parent path
List<String> 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<String> 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<String> 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<String> readLines(@NotNull InputStream is) throws IOException {
try (var r = new BufferedReader(new InputStreamReader(is))) {
return r.lines().toList();
}
}
private static List<String> readLines(InputStream in) throws IOException {
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return readLines(r);
}
@NotNull
private static List<String> readLines(@NotNull File file) throws IOException {
return Files.readAllLines(file.toPath());
}
private static List<String> readLines(BufferedReader reader) throws IOException {
List<String> lines = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) lines.add(line);
return lines;
@NotNull
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
if (keys == null) return Set.of();
Set<String> s = new HashSet<>();
for (String k : keys) s.add(k.trim().toLowerCase());
return s;
}
private static void writeLines(File file, List<String> 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<String> flatKeys(File file) throws IOException {
Set<String> keys = new LinkedHashSet<>();
Deque<String> stack = new ArrayDeque<>();
List<String> 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<String> normalizedIgnored(@Nullable List<String> list) {
if (list == null || list.isEmpty()) return Collections.emptySet();
Set<String> result = new LinkedHashSet<>();
for (String s : list) result.add(s.toLowerCase(Locale.ROOT).trim());
return result;
}
private static boolean isIgnored(String fullPath, Set<String> 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<String> 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<String> before, List<String> after) {
return Math.max(0, after.size() - before.size());
}
}

View file

@ -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<String, Boolean> JUMP_TYPE_ENABLED;
public static java.util.Map<String, Double> 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() {}
}

View file

@ -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<String> resourceNodes;
private static final Map<String, FileConfiguration> 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<String, FileConfiguration> 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<String> 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<String> 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<String, FileConfiguration> 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<String, FileConfiguration> 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<Path> 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<String> 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<String> getStringList(String locale, String path) {
return getValue(locale, config -> config.getStringList(path), Collections.emptyList());
}
private static <T> T getValue(String locale, Function<FileConfiguration, T> 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<String, FileConfiguration> loadFromDisk(@NotNull FileConfiguration embedded) throws Exception {
Map<String, FileConfiguration> result = new HashMap<>();
File folder = LoParkour.getInFolder("locales");
ensureFolder(folder);
return getItem(locale, path, replace);
try (Stream<Path> 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<String> refNodes = getChildren(reference);
List<String> 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<String> 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> T cachedValue(@NotNull String locale, @NotNull Function<FileConfiguration, T> 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<String> 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";
}
}

View file

@ -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:
* <ul>
* <li>{@link SqlOptions} – SQL connection settings</li>
* <li>{@link ParticleOptions} – particle &amp; sound settings</li>
* <li>{@link GenerationOptions} – generation &amp; jump-validation settings</li>
* </ul>
*
* 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<String, Boolean> JUMP_TYPE_ENABLED;
public static Map<String, Double> 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<Style> initStyles(String path, FileConfiguration config, BiFunction<String, List<Material>, Style> fn) {
var styles = new HashSet<Style>();
for (String style : Locales.getChildren(config, path, false)) {
ConfigAccessor accessor = new ConfigAccessor(config, "config.yml");
for (String style : accessor.getChildren(path, false)) {
styles.add(fn.apply(style,
config.getStringList("%s.%s".formatted(path, style)).stream()
.map(name -> {
Material m = Material.getMaterial(name.toUpperCase());
Material m = Materials.parse(name);
if (m == null) {
LoParkour.getPlugin().getLogger().severe("Invalid material %s in style %s".formatted(name, style));
return Material.STONE;

View file

@ -1,44 +0,0 @@
package dev.loki.loparkour.config;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.util.ParticleData;
import org.bukkit.Particle;
import org.bukkit.Sound;
/**
* Particle and sound settings. Populated by {@link Option#init}.
*/
public final class ParticleOptions {
public static Option.ParticleShape SHAPE;
public static Particle PARTICLE_TYPE;
public static ParticleData<?> PARTICLE_DATA;
public static Sound SOUND_TYPE;
public static int SOUND_PITCH;
public static int SOUND_VOLUME;
static void init() {
String sound = Config.CONFIG.getString("particles.sound-type").toUpperCase();
try {
SOUND_TYPE = Sound.valueOf(sound);
} catch (IllegalArgumentException ex) {
SOUND_TYPE = Sound.valueOf("BLOCK_NOTE_PLING");
LoParkour.getPlugin().getLogger().severe("Invalid sound: " + sound);
}
String particle = Config.CONFIG.getString("particles.particle-type");
try {
PARTICLE_TYPE = Particle.valueOf(particle);
} catch (IllegalArgumentException ex) {
PARTICLE_TYPE = Particle.valueOf("SPELL_INSTANT");
LoParkour.getPlugin().getLogger().severe("Invalid particle: " + particle);
}
SOUND_PITCH = Config.CONFIG.getInt("particles.sound-pitch");
SOUND_VOLUME = Config.CONFIG.getInt("particles.sound-volume");
SHAPE = Option.ParticleShape.valueOf(Config.CONFIG.getString("particles.particle-shape").toUpperCase());
PARTICLE_DATA = new ParticleData<>(PARTICLE_TYPE, null, 10);
}
private ParticleOptions() {}
}

View file

@ -1,27 +0,0 @@
package dev.loki.loparkour.config;
/**
* SQL connection settings. Populated by {@link Option#init}.
*/
public final class SqlOptions {
public static boolean SQL;
public static int PORT;
public static String URL;
public static String DB;
public static String USERNAME;
public static String PASSWORD;
public static String PREFIX;
static void init() {
SQL = Config.CONFIG.getBoolean("sql.enabled");
PORT = Config.CONFIG.getInt("sql.port");
DB = Config.CONFIG.getString("sql.database");
URL = Config.CONFIG.getString("sql.url");
USERNAME = Config.CONFIG.getString("sql.username");
PASSWORD = Config.CONFIG.getString("sql.password");
PREFIX = Config.CONFIG.getString("sql.prefix");
}
private SqlOptions() {}
}

View file

@ -0,0 +1,143 @@
package dev.loki.loparkour.generator;
import dev.loki.loparkour.config.Config;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
/**
* Manages generator profiles and difficulty calculations.
*/
public class GeneratorProfileManager {
private final ParkourGenerator generator;
public GeneratorProfileManager(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Override profile settings based on generator options.
*/
public void overrideProfile() {
// Generator options are for disabling features, not difficulty
// Difficulty is controlled by profile settings
// This method can be used to apply custom profile overrides if needed
}
/**
* Calculate generation chances based on current profile.
*/
public void calculateChances() {
calculateDistanceChances();
calculateHeightChances();
calculateDefaultChances();
calculateSpecialChances();
}
/**
* Calculate difficulty score based on current settings.
*/
public double getDifficultyScore() {
double score = 0.0;
// Distance difficulty
for (Map.Entry<Integer, Double> entry : generator.state.distanceChances.entrySet()) {
score += entry.getKey() * entry.getValue() * 0.3;
}
// Height difficulty
for (Map.Entry<Integer, Double> entry : generator.state.heightChances.entrySet()) {
score += Math.abs(entry.getKey()) * entry.getValue() * 0.2;
}
// Special blocks difficulty
for (Map.Entry<BlockData, Double> entry : generator.state.specialChances.entrySet()) {
score += entry.getValue() * 0.5;
}
return Math.round(score * 100.0) / 100.0;
}
private void calculateDistanceChances() {
generator.state.distanceChances.clear();
// Get distance range from config
int minDistance = Config.GENERATION.getInt("advanced.distance.min", 1);
int maxDistance = Config.GENERATION.getInt("advanced.distance.max", 4);
for (int distance = minDistance; distance <= maxDistance; distance++) {
int chance = Config.GENERATION.getInt("advanced.distance.chances." + distance, 1);
generator.state.distanceChances.put(distance, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.distanceChances);
}
private void calculateHeightChances() {
generator.state.heightChances.clear();
// Get height range from config
int minHeight = Config.GENERATION.getInt("advanced.height.min", -1);
int maxHeight = Config.GENERATION.getInt("advanced.height.max", 2);
for (int height = minHeight; height <= maxHeight; height++) {
int chance = Config.GENERATION.getInt("advanced.height.chances." + height, 1);
generator.state.heightChances.put(height, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.heightChances);
}
private void calculateDefaultChances() {
generator.state.defaultChances.clear();
int defaultChance = Config.GENERATION.getInt("advanced.type.default", 80);
int specialChance = Config.GENERATION.getInt("advanced.type.special", 15);
int schematicChance = Config.GENERATION.getInt("advanced.type.schematic", 5);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.DEFAULT, (double) defaultChance);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SPECIAL, (double) specialChance);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SCHEMATIC, (double) schematicChance);
// Normalize chances to sum to 1.0
normalizeMap(generator.state.defaultChances);
}
private void calculateSpecialChances() {
generator.state.specialChances.clear();
try {
for (String specialType : Config.GENERATION.getChildren("advanced.special")) {
String materialPath = "advanced.special." + specialType + ".material";
String chancePath = "advanced.special." + specialType + ".chance";
String materialName = Config.GENERATION.getString(materialPath);
int chance = Config.GENERATION.getInt(chancePath, 1);
BlockData blockData = org.bukkit.Bukkit.createBlockData(materialName);
generator.state.specialChances.put(blockData, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.specialChances);
} catch (Exception e) {
// If special blocks config is missing, just skip
}
}
/**
* Normalize a map so all values sum to 1.0
*/
private <K> void normalizeMap(Map<K, Double> map) {
if (map.isEmpty()) return;
double sum = map.values().stream().mapToDouble(Double::doubleValue).sum();
if (sum > 0) {
map.replaceAll((k, v) -> v / sum);
}
}
}

View file

@ -25,12 +25,13 @@ public class GeneratorState {
public Location playerSpawn;
public Location lastStandingPlayerLocation;
public Vector heading;
public Location[] zone; // [min, max] bounding box for generation area
// Time state
public Instant start;
// Block history
public List<Block> history = new LinkedList<>();
// Block history (sync list: all mutations occur on the main thread; avoids accidental CME if extended later)
public final List<Block> history = Collections.synchronizedList(new LinkedList<>());
public int lastPositionIndexPlayer = -1;
// Schematic state

View file

@ -0,0 +1,97 @@
package dev.loki.loparkour.generator;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Manages generator statistics and time formatting.
*/
public class GeneratorStatistics {
private final ParkourGenerator generator;
public GeneratorStatistics(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Get formatted time string.
*/
@NotNull
public String getFormattedTime() {
return getTime("mm:ss.SSS");
}
/**
* Get detailed time string.
*/
@NotNull
public String getDetailedTime() {
return getTime("HH:mm:ss.SSS");
}
/**
* Register score in leaderboard.
*/
public void registerScore(@NotNull String time, @NotNull String difficulty, int score) {
Mode mode = generator.getMode();
Leaderboard leaderboard = mode.getLeaderboard();
for (ParkourPlayer player : generator.getPlayers()) {
Score playerScore = new Score(
player.player.getName(),
time,
difficulty,
score
);
leaderboard.put(player.getUUID(), playerScore);
}
}
/**
* Send fall message to players.
*/
public void sendFallMessage(int record) {
for (ParkourPlayer player : generator.getPlayers()) {
String message;
if (generator.state.score > record) {
// New record
message = Locales.getString(player.player, "parkour.fall.new-record")
.replace("%score%", String.valueOf(generator.state.score))
.replace("%time%", getFormattedTime());
} else {
// Regular fall
message = Locales.getString(player.player, "parkour.fall.regular")
.replace("%score%", String.valueOf(generator.state.score))
.replace("%record%", String.valueOf(record))
.replace("%time%", getFormattedTime());
}
player.player.sendMessage(message);
}
}
@NotNull
private String getTime(@NotNull String format) {
if (generator.state.start == null) {
return "00:00.000";
}
long elapsed = java.time.Duration.between(generator.state.start, java.time.Instant.now()).toMillis();
Date date = new Date(elapsed);
SimpleDateFormat formatter = new SimpleDateFormat(format);
formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return formatter.format(date);
}
}

View file

@ -1,40 +1,36 @@
package dev.loki.loparkour.generator;
import java.util.ArrayList;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.Materials;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
/**
* Spawn island handler.
*
* @author loki
* @since 5.0.0
* Spawn area: flat platform at the session cell (no island schematic).
*/
public final class Island {
/**
* The session.
*/
/** Half-size: platform is (2*R+1) x (2*R+1) blocks. */
private static final int PLATFORM_RADIUS = 4;
public final Session session;
/**
* The schematic.
* Unused for spawn; kept for API / {@link dev.loki.loparkour.generator.jump.BlockPlacer} schematic branch.
*/
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
/**
* The blocks that have been affected by the schematic.
*/
public List<Block> blocks;
public Island(@NotNull Session session, @Nullable dev.loki.loparkour.schematic.lpschem.LPSchematic schematic) {
@ -42,62 +38,64 @@ public final class Island {
this.schematic = schematic;
}
/**
* Builds the island and teleports the player.
*/
public void build(Location location) {
if (schematic == null) {
LoParkour.getPlugin().getLogger().warning("Cannot build island - schematic is null!");
World world = location.getWorld();
if (world == null) {
return;
}
blocks = schematic.paste(location, location.getWorld());
int cx = location.getBlockX();
int cy = location.getBlockY();
int cz = location.getBlockZ();
Material playerMaterial = Material.getMaterial(Config.GENERATION.getString("advanced.island.spawn.player-block").toUpperCase());
Material parkourMaterial = Material.getMaterial(Config.GENERATION.getString("advanced.island.parkour.begin-block").toUpperCase());
// Fallback for first-block-material if not present in config
String firstBlockStr = "stone";
try {
firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
} catch (NoSuchElementException ex) {
LoParkour.getPlugin().getLogger().warning("Config key 'advanced.island.parkour.first-block-material' not found, using 'stone' as default");
BlockFace face = Option.HEADING;
int hx = face.getModX();
int hz = face.getModZ();
if (session.generator.state.heading == null) {
session.generator.state.heading = face.getDirection();
}
Material firstBlockMaterial = Material.getMaterial(firstBlockStr.toUpperCase());
Vector h = session.generator.state.heading;
try {
Block player = blocks.stream().filter(block -> block.getType() == playerMaterial).findAny().orElseThrow();
Block parkour = blocks.stream().filter(block -> block.getType() == parkourMaterial).findAny().orElseThrow();
String firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
if (firstBlockStr.isBlank()) {
firstBlockStr = "stone";
}
Material platformMat = Materials.parseOr(firstBlockStr, Material.STONE);
player.setType(firstBlockMaterial);
parkour.setType(firstBlockMaterial);
List<Block> placed = new ArrayList<>();
for (int dx = -PLATFORM_RADIUS; dx <= PLATFORM_RADIUS; dx++) {
for (int dz = -PLATFORM_RADIUS; dz <= PLATFORM_RADIUS; dz++) {
Block b = world.getBlockAt(cx + dx, cy, cz + dz);
b.setType(platformMat);
placed.add(b);
}
}
Location ps = player.getLocation().add(0.5, 1.0, 0.5);
ps.setYaw(Config.GENERATION.getInt("advanced.island.spawn.yaw"));
ps.setPitch(Config.GENERATION.getInt("advanced.island.spawn.pitch"));
// Player stand 2 blocks "behind" center along heading; course line continues +4 then first jump +4 more (old island logic)
int px = cx - 2 * hx;
int pz = cz - 2 * hz;
int qx = px + 4 * hx;
int qz = pz + 4 * hz;
// First parkour block position - fixed offset from parkour marker
// Always 4 blocks in the direction of heading from center
Location parkourStart = parkour.getLocation().clone();
parkourStart.add(
session.generator.state.heading.getX() * 4,
Block playerFoot = world.getBlockAt(px, cy, pz);
Location ps = playerFoot.getLocation().add(0.5, 1.0, 0.5);
ps.setYaw((float) Config.GENERATION.getInt("advanced.island.spawn.yaw"));
ps.setPitch((float) Config.GENERATION.getInt("advanced.island.spawn.pitch"));
Location parkourStart = world.getBlockAt(qx, cy, qz).getLocation().add(
h.getX() * 4,
0,
session.generator.state.heading.getZ() * 4
);
session.generator.generateFirst(ps, parkourStart);
session.generator.startTick();
session.getPlayers().forEach(pp -> pp.setup(ps));
} catch (NoSuchElementException ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to find parkour or player spawn in schematic %s - check if you used the same material as the one in generation.yml - ".formatted("schematic") + ex.getMessage());
h.getZ() * 4);
blocks.forEach(block -> block.setType(Material.AIR));
}
session.generator.generateFirst(ps, parkourStart);
session.generator.startTick();
session.getPlayers().forEach(pp -> pp.setup(ps));
this.blocks = placed;
}
/**
* Destroys the island.
*/
public void destroy() {
if (blocks == null) {
return;

View file

@ -1,13 +1,7 @@
package dev.loki.loparkour.generator;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.effect.EffectManager;
import dev.loki.loparkour.generator.jump.BlockPlacer;
import dev.loki.loparkour.generator.lifecycle.GeneratorLifecycle;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
@ -15,219 +9,121 @@ import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.List;
/**
* Refactored ParkourGenerator using composition pattern.
* Coordinates profile management, statistics, and core generation.
*/
public class ParkourGenerator {
public static final int BLOCK_TRAIL = 2;
public final GeneratorState state = new GeneratorState();
public final GeneratorLifecycle lifecycle;
public final EffectManager effects;
public final BlockPlacer placer;
public final Session session;
public final ParkourPlayer player;
public final Profile profile = new Profile();
public final Island island;
public final List<GeneratorOption> generatorOptions;
public Location[] zone;
public dev.lolib.scheduler.ScheduledTask task;
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic,
GeneratorOption... generatorOptions) {
public final Profile profile;
public final GeneratorState state;
public final GeneratorOption[] generatorOptions;
protected final BlockPlacer placer;
protected final GeneratorLifecycle lifecycle;
public final GeneratorProfileManager profileManager;
private final GeneratorStatistics statistics;
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic, GeneratorOption... generatorOptions) {
this.session = session;
this.generatorOptions = Arrays.asList(generatorOptions);
this.player = session.getPlayers().get(0);
this.generatorOptions = generatorOptions;
this.island = new Island(session, schematic);
this.zone = Divider.toSelection(session);
this.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
this.state.heading = Option.HEADING.getDirection();
this.profile = new Profile();
this.state = new GeneratorState();
this.effects = new EffectManager(this);
// Initialize zone from session
if (session != null) {
Location[] selection = dev.loki.loparkour.world.Divider.toSelection(session);
this.state.zone = selection;
}
// Initialize components
this.placer = new BlockPlacer(this);
this.lifecycle = new GeneratorLifecycle(this);
this.profileManager = new GeneratorProfileManager(this);
this.statistics = new GeneratorStatistics(this);
calculateChances();
// Setup initial state
profileManager.overrideProfile();
profileManager.calculateChances();
}
public ParkourGenerator(@NotNull Session session, GeneratorOption... generatorOptions) {
this(session, loadIslandSchematic(), generatorOptions);
this(session, null, generatorOptions);
}
private static LPSchematic loadIslandSchematic() {
String name = Config.GENERATION.getString("advanced.island.schematic-name");
if (name == null || name.isEmpty()) {
name = "island"; // Default fallback
}
var manager = LoParkour.getSchematicManager();
if (manager == null) {
LoParkour.getPlugin().getLogger().warning("Schematic manager not initialized, using null island");
return null;
}
LPSchematic s = manager.getSchematic(name);
if (s == null) {
LoParkour.getPlugin().getLogger().warning("Island schematic '%s' not found!".formatted(name));
// Try fallback to default "island" schematic
if (!"island".equals(name)) {
LoParkour.getPlugin().getLogger().info("Attempting to load default 'island' schematic as fallback...");
s = manager.getSchematic("island");
if (s != null) {
LoParkour.getPlugin().getLogger().info("Successfully loaded fallback 'island' schematic");
} else {
LoParkour.getPlugin().getLogger().severe("Fallback 'island' schematic also not found! Island generation will be disabled.");
}
}
}
return s;
}
public void overrideProfile() {}
protected void calculateChances() {
state.defaultChances.clear();
state.defaultChances.put(BlockGenerationType.DEFAULT, Option.TYPE_NORMAL);
// Schematics disabled - only normal and special blocks
state.defaultChances.put(BlockGenerationType.SPECIAL, Option.TYPE_SPECIAL);
state.heightChances.clear();
state.heightChances.put(1, Option.NORMAL_HEIGHT_1);
state.heightChances.put(0, Option.NORMAL_HEIGHT_0);
state.heightChances.put(-1, Option.NORMAL_HEIGHT_NEG1);
state.heightChances.put(-2, Option.NORMAL_HEIGHT_NEG2);
state.distanceChances.clear();
state.distanceChances.put(1, Option.NORMAL_DISTANCE_1);
state.distanceChances.put(2, Option.NORMAL_DISTANCE_2);
state.distanceChances.put(3, Option.NORMAL_DISTANCE_3);
state.distanceChances.put(4, Option.NORMAL_DISTANCE_4);
state.specialChances.clear();
state.specialChances.put(Material.PACKED_ICE.createBlockData(), Option.SPECIAL_ICE);
state.specialChances.put(Material.SMOOTH_QUARTZ_SLAB.createBlockData("[type=bottom]"), Option.SPECIAL_SLAB);
state.specialChances.put(Material.GLASS_PANE.createBlockData(), Option.SPECIAL_PANE);
state.specialChances.put(Material.OAK_FENCE.createBlockData(), Option.SPECIAL_FENCE);
}
// Core generation methods
public void generate() { placer.generate(); }
public void generate(int amount) { placer.generate(amount); }
public void generateFirst(Location spawn, Location block) { placer.generateFirst(spawn, block); }
// Lifecycle methods
public void startTick() { lifecycle.startTick(); }
public void tick() { lifecycle.tick(); }
protected void fall() { lifecycle.fall(); }
protected void score() { lifecycle.score(); }
public void reset(boolean regenerate) {
state.stopped = !regenerate;
if (!regenerate && task == null) {
LoParkour.getPlugin().getLogger().warning("## Incomplete joining setup — report this!");
}
state.lastPositionIndexPlayer = 0;
if (!state.history.isEmpty()) {
Block first = state.history.remove(0);
// Create a copy to avoid ConcurrentModificationException
List<Block> historySnapshot = new ArrayList<>(state.history);
historySnapshot.forEach(b -> b.setType(Material.AIR, false));
state.history.clear();
}
state.resetSchematicState();
placer.deleteSchematic();
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
if (profile.get("showFallMessage").asBoolean()) {
sendFallMessage(record);
}
if (lb != null && state.score > record) {
registerScore(getDetailedTime(), Double.toString(getDifficultyScore()).substring(0, 3), state.score);
}
state.resetScore();
state.heading = Option.HEADING.getDirection();
lifecycle.reset(regenerate);
if (regenerate) {
player.teleport(state.playerSpawn);
generateFirst(state.playerSpawn, state.blockSpawn);
return;
}
island.destroy();
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
profileManager.overrideProfile();
profileManager.calculateChances();
}
}
private void sendFallMessage(int record) {
String key;
int number = 0;
if (state.score == record) {
key = "settings.parkour_settings.items.fall_message.formats.tied";
} else if (state.score > record) {
key = "settings.parkour_settings.items.fall_message.formats.beat";
number = state.score - record;
} else {
key = "settings.parkour_settings.items.fall_message.formats.miss";
number = record - state.score;
}
for (ParkourPlayer p : getPlayers()) {
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
p.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(state.score));
p.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
p.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
p.sendTranslated(key, Integer.toString(number));
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
// Statistics and scoring
public void registerScore(String time, String difficulty, int score) {
statistics.registerScore(time, difficulty, score);
}
protected void registerScore(String time, String difficulty, int score) {
Leaderboard lb = getMode().getLeaderboard();
if (lb == null) return;
getPlayers().forEach(p -> lb.put(p.getUUID(), new Score(p.getName(), time, difficulty, score)));
}
public void menu(ParkourPlayer player) { Menus.PARKOUR_SETTINGS.open(player); }
public Block getLatest() { return state.history.get(state.history.size() - 1); }
public double getDifficultyScore() {
double s = 0;
if (profile.get("useSpecialBlocks").asBoolean()) s += 0.5;
double d = profile.get("schematicDifficulty").asDouble();
if (d > 0) s += d <= 0.25 ? 0.2 : d <= 0.5 ? 0.3 : d <= 0.75 ? 0.4 : 0.5;
return s;
return profileManager.getDifficultyScore();
}
public String getFormattedTime() {
return getTime(Config.CONFIG.getString("options.time.score-format"));
return statistics.getFormattedTime();
}
public String getDetailedTime() {
return getTime("mm:ss:SSS");
return statistics.getDetailedTime();
}
private String getTime(String format) {
var ms = Instant.now().minusMillis(state.start != null ? state.start.toEpochMilli() : Instant.now().toEpochMilli());
try {
return DateTimeFormatter.ofPattern(format).withZone(ZoneOffset.UTC).format(ms);
} catch (IllegalArgumentException ex) {
LoParkour.getPlugin().getLogger().severe("Invalid time format: " + format);
return "";
}
// UI and player management
public void menu(ParkourPlayer player) {
Menus.PARKOUR_SETTINGS.open(player);
}
public Mode getMode() { return Modes.DEFAULT; }
@NotNull public List<ParkourPlayer> getPlayers() { return session.getPlayers(); }
@NotNull public List<ParkourSpectator> getSpectators() { return session.getSpectators(); }
public enum BlockGenerationType { DEFAULT, SCHEMATIC, SPECIAL }
}
public Mode getMode() {
return Modes.DEFAULT;
}
@NotNull
public List<ParkourPlayer> getPlayers() {
return session.getPlayers();
}
@NotNull
public List<ParkourSpectator> getSpectators() {
return session.getSpectators();
}
/** Last generated block, or {@code null} if history is empty. */
public @Nullable Block getLatest() {
if (state.history.isEmpty()) return null;
return state.history.get(state.history.size() - 1);
}
public enum BlockGenerationType {
DEFAULT, SPECIAL, SCHEMATIC
}
}

View file

@ -3,7 +3,6 @@ package dev.loki.loparkour.generator.effect;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.util.ParticleUtil;
import org.bukkit.Location;
import org.bukkit.block.Block;

View file

@ -1,308 +1,176 @@
package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.GeneratorOption;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.util.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Fence;
import org.bukkit.block.data.type.GlassPane;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Level;
/**
* Handles block selection, placement, and schematic generation.
* Extracted from {@link ParkourGenerator}.
* Refactored BlockPlacer using composition pattern.
* Coordinates block selection, jump calculation, and placement.
*/
public class BlockPlacer {
private final ParkourGenerator g;
public BlockPlacer(ParkourGenerator generator) {
this.g = generator;
}
// ── Public entry points ────────────────────────────────────────────────────
/** Generates one block or schematic ahead. */
public void generate() {
if (g.state.waitForSchematicCompletion) return;
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.state.defaultChances);
if (g.state.schematicCooldown > 0
|| g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS)
|| g.profile.get("schematicDifficulty").asDouble() == 0.0
|| !g.state.schematicBlocks.isEmpty()) {
chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC);
}
if (!g.profile.get("useSpecialBlocks").asBoolean()) {
chances.remove(ParkourGenerator.BlockGenerationType.SPECIAL);
}
if (chances.isEmpty()) chances.put(ParkourGenerator.BlockGenerationType.DEFAULT, 1.0);
ParkourGenerator.BlockGenerationType jump = Probs.random(chances);
if (jump == ParkourGenerator.BlockGenerationType.SCHEMATIC && tryGenerateSchematic()) return;
placeNormalBlock(jump);
}
/** Generates {@code amount+1} blocks. */
public void generate(int amount) {
for (int i = 0; i <= amount; i++) generate();
}
/** Seeds the history and generates the initial lead. */
public void generateFirst(Location spawn, Location blockSpawn) {
g.state.playerSpawn = spawn;
g.state.lastStandingPlayerLocation = spawn;
g.state.blockSpawn = blockSpawn;
g.state.history.add(blockSpawn.getBlock());
generate(g.profile.get("blockLead").asInt());
}
/** Clears schematic blocks and resets cooldown. */
public void deleteSchematic() {
if (!g.state.deleteSchematic) return;
g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
g.state.schematicBlocks.clear();
g.state.deleteSchematic = false;
g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
}
// ── Block selection ────────────────────────────────────────────────────────
protected List<Block> selectBlocks() {
int height = Probs.random(g.state.heightChances);
int distance = Probs.random(g.state.distanceChances);
return List.of(selectNext(g.getLatest(), distance, height));
}
protected Block selectNext(Block current, int distance, int height) {
JumpDirector director = new JumpDirector(
BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector());
g.state.heading = director.getRecommendedHeading(g.state.heading);
height = director.getRecommendedHeight(height);
// Restrict jumps after special blocks to prevent impossible jumps
Material lastType = g.getLatest().getType();
BlockData lastBlockData = g.getLatest().getBlockData();
// Check if it's a slab material
if (isSlabMaterial(lastType)) {
// Check if it's a bottom slab (height 0.5) or top slab (height 1.0)
if (lastBlockData instanceof org.bukkit.block.data.type.Slab slab) {
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
// Bottom slab: player is 0.5 blocks lower, can't jump as high
height = Math.min(height, -1); // Reduce max height due to lower starting position
distance = Math.min(distance, 3); // Limit distance
} else if (slab.getType() == org.bukkit.block.data.type.Slab.Type.TOP) {
// Top slab: normal restrictions
height = Math.min(height, 0);
distance = Math.min(distance, 3);
}
} else {
// Fallback for unknown slab type
height = Math.min(height, 0);
distance = Math.min(distance, 3);
}
} else {
switch (lastType) {
case GLASS_PANE -> {
height = Math.min(height, 0); // Can't jump up from pane
distance = Math.min(distance, 3); // Limit distance
}
case OAK_FENCE, BIRCH_FENCE, SPRUCE_FENCE, DARK_OAK_FENCE,
JUNGLE_FENCE, ACACIA_FENCE, MANGROVE_FENCE, CHERRY_FENCE,
CRIMSON_FENCE, WARPED_FENCE, NETHER_BRICK_FENCE -> {
height = Math.min(height, 0); // Can't jump up from fence
distance = Math.min(distance, 3); // Limit distance
}
case PACKED_ICE -> {
distance = Math.min(distance, 3); // Ice is slippery, limit distance
}
case OAK_TRAPDOOR, BIRCH_TRAPDOOR, SPRUCE_TRAPDOOR, DARK_OAK_TRAPDOOR,
JUNGLE_TRAPDOOR, ACACIA_TRAPDOOR, MANGROVE_TRAPDOOR, CHERRY_TRAPDOOR,
CRIMSON_TRAPDOOR, WARPED_TRAPDOOR, IRON_TRAPDOOR -> {
height = Math.min(height, 0); // Can't jump up from trapdoor
distance = Math.min(distance, 2); // Very limited distance
}
case LADDER -> {
height = Math.min(height, 1); // Limited height from ladder
distance = Math.min(distance, 2); // Very limited distance
}
}
}
// Clamp values to valid ranges for JumpOffsetGenerator
height = Math.max(-2, Math.min(1, height));
distance = Math.max(1, Math.min(4, distance));
if (height > 0) distance = Math.max(distance - height, 1);
double sd = g.generatorOptions.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
Vector offset = g.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) offset.setX(randomOffset);
else offset.setZ(randomOffset);
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
Block candidate = current.getLocation().add(offset).getBlock();
// Retry with JumpValidator if jump is impossible (up to 10 attempts)
JumpValidator validator = new JumpValidator();
int attempts = 0;
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
// Reduce distance and height to make jump easier
distance = Math.max(1, distance - 1);
height = Math.max(-1, height - 1);
randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
offset = g.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) offset.setX(randomOffset);
else offset.setZ(randomOffset);
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
candidate = current.getLocation().add(offset).getBlock();
attempts++;
}
return candidate;
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
private final JumpCalculator jumpCalculator;
public BlockPlacer(@NotNull ParkourGenerator generator) {
this.generator = generator;
this.blockSelector = new BlockSelector(generator);
this.jumpCalculator = new JumpCalculator(generator, blockSelector);
}
/**
* Check if material is a slab type.
* Generate a single block.
*/
private boolean isSlabMaterial(Material material) {
return material == Material.SMOOTH_QUARTZ_SLAB ||
material == Material.STONE_SLAB ||
material.name().endsWith("_SLAB");
}
protected BlockData selectBlockData() {
Style style = Registry.getStyle(g.profile.get("style").value());
if (style == null) {
g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
return selectBlockData();
}
return style.getNext().createBlockData();
}
// ── Internal ───────────────────────────────────────────────────────────────
private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) {
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) {
LoParkour.getPlugin().getLogger().severe("No blocks to generate!");
return;
}
List<Block> placed = new ArrayList<>();
for (Block block : blocks) {
BlockData data;
// Select JumpType based on chances (70% normal, 30% special types)
JumpType jumpType = selectJumpType();
if (jumpType != JumpType.NORMAL && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
// Use JumpType material
data = jumpType.getRandomMaterial().createBlockData();
} else if (jump == ParkourGenerator.BlockGenerationType.SPECIAL
&& !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
data = Probs.random(g.state.specialChances);
} else {
data = selectBlockData();
}
if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock();
block.setBlockData(data, data instanceof Fence || data instanceof GlassPane);
placed.add(block);
}
new ParkourBlockGenerateEvent(placed, g, g.player).call();
g.effects.particles(placed);
g.effects.sound(placed);
g.state.history.addAll(placed);
g.state.schematicCooldown--;
public void generate() {
generate(1);
}
private JumpType selectJumpType() {
// Simple chances: 70% normal, 5% each for special types
Map<JumpType, Double> chances = new HashMap<>();
chances.put(JumpType.NORMAL, 70.0);
chances.put(JumpType.NEO_JUMP, 5.0);
chances.put(JumpType.HEAD_HITTER, 5.0);
chances.put(JumpType.FENCE_JUMP, 10.0);
chances.put(JumpType.TRAPDOOR_JUMP, 5.0);
chances.put(JumpType.LADDER_JUMP, 5.0);
return Probs.random(chances);
}
private boolean tryGenerateSchematic() {
var manager = LoParkour.getSchematicManager();
if (manager == null) return false;
var all = manager.getAllSchematics();
if (all.isEmpty()) return false;
double diff = g.profile.get("schematicDifficulty").asDouble();
var candidates = all.values().stream()
.filter(s -> s.getMetadata() != null && Math.abs(s.getMetadata().getDifficulty() - diff) <= 0.26)
.toList();
var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates;
var schematic = pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2));
List<Block> placed = rotatedPaste(schematic, origin);
if (placed.isEmpty()) return false;
g.state.schematicBlocks.addAll(placed);
g.state.history.addAll(placed);
g.state.waitForSchematicCompletion = true;
g.state.deleteSchematic = false;
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
return true;
}
private @NotNull List<Block> rotatedPaste(
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, Location location) {
int[] rotations = {0, 90, 180, 270};
int rotation = rotations[ThreadLocalRandom.current().nextInt(rotations.length)];
org.bukkit.World world = location.getWorld();
if (world == null) return List.of();
if (rotation == 0) return schematic.paste(location, world);
try {
var t = new dev.loki.loparkour.schematic.lpschem.SchematicTransformer(schematic);
return t.rotate(rotation).paste(location, world);
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().log(Level.WARNING, "Could not rotate schematic", ex);
return schematic.paste(location, world);
/**
* Generate specified amount of blocks.
*/
public void generate(int amount) {
for (int i = 0; i < amount; i++) {
generateSingleBlock();
}
}
private double angleInY(Vector a, Vector b) {
return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b));
/**
* Generate the first block at specified locations.
*/
public void generateFirst(@NotNull Location spawn, @NotNull Location blockSpawn) {
Block block = blockSpawn.getBlock();
BlockData blockData = blockSelector.selectBlockData();
placeBlockData(block, blockData);
generator.state.history.add(block);
generator.state.playerSpawn = spawn;
}
}
/**
* Delete schematic blocks (if any).
*/
public void deleteSchematic() {
if (generator.state.schematicBlocks != null) {
generator.state.schematicBlocks.forEach(block -> block.setType(org.bukkit.Material.AIR));
generator.state.schematicBlocks.clear();
}
}
private void generateSingleBlock() {
ParkourGenerator.BlockGenerationType jumpType = selectGenerationType();
switch (jumpType) {
case DEFAULT -> placeNormalBlock();
case SPECIAL -> placeSpecialBlock();
case SCHEMATIC -> {
if (!tryGenerateSchematic()) {
placeNormalBlock(); // Fallback to normal block
}
}
}
}
@NotNull
private ParkourGenerator.BlockGenerationType selectGenerationType() {
return Probs.random(generator.state.defaultChances);
}
private void placeNormalBlock() {
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) return;
Block selectedBlock = blocks.get(0); // Use first block for simplicity
BlockData blockData = blockSelector.selectBlockData();
placeBlockData(selectedBlock, blockData);
generator.state.history.add(selectedBlock);
}
private void placeSpecialBlock() {
// Select special block type based on probabilities
BlockData specialBlockData = Probs.random(generator.state.specialChances);
if (specialBlockData == null) {
placeNormalBlock();
return;
}
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) return;
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, specialBlockData);
generator.state.history.add(selectedBlock);
}
/** Prefer no neighbor physics to avoid fragile vanilla multi-block checks in empty worlds. */
private static void placeBlockData(@NotNull Block block, @NotNull BlockData data) {
block.setBlockData(data, false);
}
@NotNull
private List<Block> selectBlocks() {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks; // No previous blocks to work with
}
Block current = generator.getLatest();
// Select jump parameters
int distance = Probs.random(generator.state.distanceChances);
int height = Probs.random(generator.state.heightChances);
// Calculate next block position
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
private boolean tryGenerateSchematic() {
// Simplified schematic generation
LPSchematic schematic = generator.island.schematic; // Получаем схематик из island
if (schematic == null) return false;
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) return false;
Block targetBlock = blocks.get(0);
Location pasteLocation = targetBlock.getLocation();
// Paste schematic (simplified)
List<Block> schematicBlocks = pasteSchematic(schematic, pasteLocation);
if (!schematicBlocks.isEmpty()) {
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
return true;
}
return false;
}
@NotNull
private List<Block> pasteSchematic(@NotNull LPSchematic schematic, @NotNull Location location) {
List<Block> blocks = new ArrayList<>();
// Simplified schematic pasting - would need full implementation
// For now, just place a single block
Block block = location.getBlock();
BlockData blockData = blockSelector.selectBlockData();
placeBlockData(block, blockData);
blocks.add(block);
return blocks;
}
}

View file

@ -0,0 +1,141 @@
package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.style.Style;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Handles selection of block types and materials for parkour generation.
*/
public class BlockSelector {
private final ParkourGenerator generator;
public BlockSelector(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
private static final int MAX_STYLE_PICKS = 64;
/**
* Select block data for the next block based on current style.
*/
@NotNull
public BlockData selectBlockData() {
Style style = Registry.getStyle(generator.profile.get("style").value());
if (style == null) {
generator.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
return selectBlockData();
}
for (int i = 0; i < MAX_STYLE_PICKS; i++) {
Material mat = style.getNext();
if (!isUnsafeParkourMaterial(mat)) {
return mat.createBlockData();
}
}
return Material.STONE.createBlockData();
}
/**
* Blocks whose vanilla {@code onPlace} scans neighbors (e.g. iron golem pattern for carved pumpkin)
* can NPE on high/void parkour worlds when adjacent chunks have no loaded state.
*/
static boolean isUnsafeParkourMaterial(@NotNull Material m) {
if (m == Material.CARVED_PUMPKIN || m == Material.JACK_O_LANTERN || m == Material.PUMPKIN) {
return true;
}
if (m == Material.DRAGON_EGG) {
return true;
}
String n = m.name();
if (n.endsWith("_BED")) {
return true;
}
return false;
}
/**
* Select jump type based on current configuration and probabilities.
*/
@NotNull
public JumpType selectJumpType() {
// Simple random selection for now
JumpType[] types = JumpType.values();
return types[(int) (Math.random() * types.length)];
}
/**
* Check if material is a slab type.
*/
public boolean isSlabMaterial(@NotNull Material material) {
return material == Material.SMOOTH_QUARTZ_SLAB ||
material == Material.STONE_SLAB ||
material.name().endsWith("_SLAB");
}
/**
* Get a slab material from the current player's style.
*/
@Nullable
public Material getSlabMaterialFromCurrentStyle() {
try {
Style style = Registry.getStyle(generator.profile.get("style").value());
if (style == null) return null;
// Try to get a slab material from the style multiple times
for (int i = 0; i < 20; i++) {
Material material = style.getNext();
if (isSlabMaterial(material)) {
return material;
}
}
} catch (Exception e) {
// Ignore errors and return null
}
return null;
}
/**
* Check if material is a special block type that affects jump mechanics.
*/
public boolean isSpecialMaterial(@NotNull Material material) {
return isSlabMaterial(material) ||
material == Material.GLASS_PANE ||
material == Material.PACKED_ICE ||
isFenceMaterial(material) ||
isTrapdoorMaterial(material) ||
material == Material.LADDER;
}
private boolean isFenceMaterial(@NotNull Material material) {
return material == Material.OAK_FENCE ||
material == Material.BIRCH_FENCE ||
material == Material.SPRUCE_FENCE ||
material == Material.DARK_OAK_FENCE ||
material == Material.JUNGLE_FENCE ||
material == Material.ACACIA_FENCE ||
material == Material.MANGROVE_FENCE ||
material == Material.CHERRY_FENCE ||
material == Material.CRIMSON_FENCE ||
material == Material.WARPED_FENCE ||
material == Material.NETHER_BRICK_FENCE;
}
private boolean isTrapdoorMaterial(@NotNull Material material) {
return material == Material.OAK_TRAPDOOR ||
material == Material.BIRCH_TRAPDOOR ||
material == Material.SPRUCE_TRAPDOOR ||
material == Material.DARK_OAK_TRAPDOOR ||
material == Material.JUNGLE_TRAPDOOR ||
material == Material.ACACIA_TRAPDOOR ||
material == Material.MANGROVE_TRAPDOOR ||
material == Material.CHERRY_TRAPDOOR ||
material == Material.CRIMSON_TRAPDOOR ||
material == Material.WARPED_TRAPDOOR ||
material == Material.IRON_TRAPDOOR;
}
}

View file

@ -0,0 +1,165 @@
package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.GeneratorOption;
import dev.loki.loparkour.generator.ParkourGenerator;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
/**
* Calculates jump positions and validates jump constraints.
*/
public class JumpCalculator {
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
public JumpCalculator(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector) {
this.generator = generator;
this.blockSelector = blockSelector;
}
/**
* Calculate the next block position based on current block and jump parameters.
*/
@NotNull
public Block calculateNextBlock(@NotNull Block current, int distance, int height) {
// Ensure zone is initialized
if (generator.state.zone == null || generator.state.zone.length < 2) {
throw new IllegalStateException("Generator zone not initialized");
}
JumpDirector director = new JumpDirector(
BoundingBox.of(generator.state.zone[0], generator.state.zone[1]),
current.getLocation().toVector()
);
generator.state.heading = director.getRecommendedHeading(generator.state.heading);
height = director.getRecommendedHeight(height);
// Apply material-specific restrictions
JumpConstraints constraints = calculateJumpConstraints(current);
height = Math.min(height, constraints.maxHeight);
distance = Math.min(distance, constraints.maxDistance);
// Clamp values to valid ranges
height = Math.max(-2, Math.min(1, height));
distance = Math.max(1, Math.min(4, distance));
if (height > 0) distance = Math.max(distance - height, 1);
// Calculate offset
Vector offset = calculateJumpOffset(distance, height);
Block candidate = current.getLocation().add(offset).getBlock();
// Validate jump with retry mechanism
return validateAndRetryJump(current, candidate, distance, height);
}
/**
* Calculate jump constraints based on the material of the current block.
*/
@NotNull
public JumpConstraints calculateJumpConstraints(@NotNull Block currentBlock) {
Material material = currentBlock.getType();
BlockData blockData = currentBlock.getBlockData();
// Default constraints
int maxHeight = 1;
int maxDistance = 4;
if (blockSelector.isSlabMaterial(material)) {
if (blockData instanceof org.bukkit.block.data.type.Slab slab) {
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
maxHeight = -1; // Bottom slab: reduced height capability
maxDistance = 3;
} else {
maxHeight = 0; // Top slab: normal restrictions
maxDistance = 3;
}
} else {
maxHeight = 0;
maxDistance = 3;
}
} else if (material == Material.GLASS_PANE) {
maxHeight = 0;
maxDistance = 3;
} else if (blockSelector.isSpecialMaterial(material)) {
maxHeight = getSpecialMaterialMaxHeight(material);
maxDistance = getSpecialMaterialMaxDistance(material);
}
return new JumpConstraints(maxHeight, maxDistance);
}
@NotNull
private Vector calculateJumpOffset(int distance, int height) {
double sd = java.util.Arrays.asList(generator.generatorOptions).contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
Vector offset = generator.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) {
offset.setX(randomOffset);
} else {
offset.setZ(randomOffset);
}
offset.rotateAroundY(angleInY(generator.state.heading, Option.HEADING.getDirection()));
return offset;
}
@NotNull
private Block validateAndRetryJump(@NotNull Block current, @NotNull Block candidate, int distance, int height) {
JumpValidator validator = new JumpValidator();
int attempts = 0;
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
// Reduce distance and height to make jump easier
distance = Math.max(1, distance - 1);
height = Math.max(-1, height - 1);
Vector offset = calculateJumpOffset(distance, height);
candidate = current.getLocation().add(offset).getBlock();
attempts++;
}
return candidate;
}
private int getSpecialMaterialMaxHeight(@NotNull Material material) {
return switch (material) {
case PACKED_ICE -> 1; // Ice allows normal height
case LADDER -> 1; // Ladder allows some height
default -> 0; // Most special materials restrict height
};
}
private int getSpecialMaterialMaxDistance(@NotNull Material material) {
return switch (material) {
case PACKED_ICE -> 3; // Ice is slippery
case LADDER -> 2; // Ladder is very restrictive
default -> 3; // Default restriction
};
}
private double angleInY(@NotNull Vector a, @NotNull Vector b) {
return Math.atan2(b.getZ(), b.getX()) - Math.atan2(a.getZ(), a.getX());
}
/**
* Represents jump constraints for a specific block type.
*/
public static class JumpConstraints {
public final int maxHeight;
public final int maxDistance;
public JumpConstraints(int maxHeight, int maxDistance) {
this.maxHeight = maxHeight;
this.maxDistance = maxDistance;
}
}
}

View file

@ -0,0 +1,120 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* Handles cleanup operations for parkour generator.
*/
public class GeneratorCleanup {
private final ParkourGenerator generator;
public GeneratorCleanup(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Remove only trail blocks <strong>behind</strong> players (oldest indices).
* Distance-based cleanup was removing blocks ahead of the player and breaking scoring.
*/
public void cleanupDistantBlocks() {
List<Block> history = generator.state.history;
if (history.isEmpty()) return;
int minStandingIndex = Integer.MAX_VALUE;
for (ParkourPlayer pp : generator.getPlayers()) {
Block below = blockBelowPlayer(pp);
if (below == null) continue;
int idx = history.indexOf(below);
if (idx >= 0) {
minStandingIndex = Math.min(minStandingIndex, idx);
}
}
if (minStandingIndex == Integer.MAX_VALUE) {
return;
}
int keepBehind = Math.max(0, Option.TRAIL_KEEP_BEHIND);
int removeCount = minStandingIndex - keepBehind;
if (removeCount <= 0) return;
List<Block> head = new ArrayList<>(history.subList(0, removeCount));
for (Block block : head) {
if (block != null && block.getType() != Material.AIR) {
block.setType(Material.AIR);
}
}
history.subList(0, removeCount).clear();
}
private static Block blockBelowPlayer(@NotNull ParkourPlayer pp) {
Location loc = pp.player.getLocation().clone();
Block below = loc.subtract(0, 1, 0).getBlock();
return below.getType() != Material.AIR ? below : null;
}
/**
* Remove trail blocks behind player at specified index.
*/
public void removeTrailBlocks(int startIndex) {
if (startIndex < 0 || startIndex >= generator.state.history.size()) return;
int endIndex = Math.min(startIndex + Option.BLOCK_CLEANUP_DISTANCE, generator.state.history.size());
for (int i = startIndex; i < endIndex; i++) {
Block block = generator.state.history.get(i);
if (block != null && block.getType() != Material.AIR) {
block.setType(Material.AIR);
}
}
// Remove from history
generator.state.history.subList(startIndex, endIndex).clear();
}
/**
* Reset generator state and clean up all blocks.
*/
public void reset(boolean regenerate) {
List<Block> snapshot = new ArrayList<>(generator.state.history);
for (Block block : snapshot) {
if (block != null) {
block.setType(Material.AIR);
}
}
// Clear schematic blocks
if (generator.state.schematicBlocks != null) {
for (Block block : generator.state.schematicBlocks) {
if (block != null) {
block.setType(Material.AIR);
}
}
generator.state.schematicBlocks.clear();
}
// Reset state
generator.state.history.clear();
generator.state.score = 0;
generator.state.start = null;
if (regenerate) {
// Regenerate first block
Location spawn = generator.state.playerSpawn;
if (spawn != null) {
Location blockSpawn = spawn.clone().add(0, -1, 0);
generator.generateFirst(spawn, blockSpawn);
}
}
}
}

View file

@ -1,265 +1,69 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.ghost.GhostData;
import dev.loki.loparkour.ghost.GhostManager;
import dev.loki.loparkour.ghost.GhostRecorder;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.reward.Rewards;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Iterator;
import org.jetbrains.annotations.NotNull;
/**
* Handles parkour lifecycle: tick, fall, reset, cleanup.
* Refactored GeneratorLifecycle using composition pattern.
* Coordinates tick management, player interactions, and visual updates.
*
* Responsibilities delegated to:
* - LifecycleTickManager: Tick cycle and periodic operations
* - PlayerInteractionHandler: Block interactions and scoring
* - LifecycleVisualUpdater: Visual updates for players
* - LifecycleEventManager: Event firing and rewards
* - GeneratorCleanup: Block cleanup operations
*/
public class GeneratorLifecycle {
private final ParkourGenerator generator;
private dev.lolib.scheduler.ScheduledTask cleanupTask;
private GhostRecorder ghostRecorder;
private GhostManager ghostManager;
public GeneratorLifecycle(ParkourGenerator generator) {
this.generator = generator;
if (Config.CONFIG.getBoolean("ghost-mode.enabled")) {
this.ghostRecorder = new GhostRecorder();
this.ghostManager = new GhostManager();
this.ghostManager.loadGhosts(generator.getMode().getName());
}
private final LifecycleTickManager tickManager;
private final PlayerInteractionHandler interactionHandler;
private final LifecycleVisualUpdater visualUpdater;
private final LifecycleEventManager eventManager;
private final GeneratorCleanup cleanup;
public GeneratorLifecycle(@NotNull ParkourGenerator generator) {
this.eventManager = new LifecycleEventManager(generator);
this.cleanup = new GeneratorCleanup(generator);
this.visualUpdater = new LifecycleVisualUpdater(generator);
this.tickManager = new LifecycleTickManager(generator, cleanup, visualUpdater);
this.interactionHandler = new PlayerInteractionHandler(generator, eventManager, cleanup);
}
/**
* Start the tick cycle.
*/
public void startTick() {
generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL);
// Start ghost recording if enabled
if (ghostRecorder != null && ghostManager != null) {
ghostRecorder.startRecording(generator.player.getLocation());
// Spawn top ghosts
if (generator.state.playerSpawn != null) {
ghostManager.spawnGhosts(
generator.getMode().getName(),
generator.state.playerSpawn,
generator.player.player.getWorld()
);
}
}
tickManager.startTick();
}
/**
* Main tick method called every server tick.
*/
public void tick() {
if (generator.state.stopped) {
generator.task.cancel();
if (cleanupTask != null) cleanupTask.cancel();
if (ghostManager != null) ghostManager.stopAllGhosts();
return;
}
generator.getPlayers().forEach(p -> {
updateVisualTime(p, p.selectedTime);
p.updateScoreboard(generator);
p.player.setSaturation(20);
// Record ghost frame
if (ghostRecorder != null && ghostRecorder.isRecording()) {
ghostRecorder.recordFrame(p.getLocation());
}
});
generator.getSpectators().forEach(ParkourSpectator::update);
if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return;
if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) {
fall();
return;
}
Block below = blockBelow();
if (below == null) return;
handleSchematicEndBlock(below);
// Clean up schematic blocks that are behind the player
if (!generator.state.schematicBlocks.isEmpty()) {
int currentIdx = generator.state.history.indexOf(below);
if (currentIdx > 0) {
generator.state.schematicBlocks.removeIf(b -> {
int blockIdx = generator.state.history.indexOf(b);
if (blockIdx >= 0 && blockIdx < currentIdx - 5) {
b.setType(Material.AIR);
return true;
}
return false;
});
}
}
// Reset schematic wait if player is far from schematic blocks
if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) {
boolean nearSchematic = generator.state.schematicBlocks.stream()
.anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15);
if (!nearSchematic) {
generator.state.waitForSchematicCompletion = false;
// Force delete schematic blocks
generator.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
generator.state.schematicBlocks.clear();
generator.state.deleteSchematic = false;
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
}
}
if (!generator.state.history.contains(below)) return;
int idx = generator.state.history.indexOf(below);
int delta = idx - generator.state.lastPositionIndexPlayer;
if (delta <= 0) return;
generator.state.lastStandingPlayerLocation = generator.player.getLocation();
int lead = generator.profile.get("blockLead").asInt();
if (generator.state.history.size() - idx <= lead) {
generator.generate(lead - (generator.state.history.size() - idx));
}
generator.state.lastPositionIndexPlayer = idx;
removeTrailBlocks(idx);
generator.placer.deleteSchematic();
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
for (int i = 0; i < pts; i++) score();
if (generator.state.start == null) generator.state.start = Instant.now();
tickManager.tick();
interactionHandler.checkAllPlayers();
}
private void handleSchematicEndBlock(Block below) {
if (generator.state.schematicBlocks.contains(below)
&& below.getType() == Material.RED_WOOL
&& !generator.state.deleteSchematic) {
for (int i = 0; i < generator.profile.get("schematicDifficulty").asDouble() * 15; i++) {
score();
}
generator.state.waitForSchematicCompletion = false;
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
generator.generate(generator.profile.get("blockLead").asInt());
generator.state.deleteSchematic = true;
}
}
private @Nullable Block blockBelow() {
Location loc = generator.player.getLocation().subtract(0, 1, 0);
Block b = loc.getBlock();
if (b.getType() == Material.AIR) {
if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null;
b = loc.getBlock();
}
return b;
}
private void removeTrailBlocks(int idx) {
for (int i = idx - ParkourGenerator.BLOCK_TRAIL - 1; i >= idx - 4 * ParkourGenerator.BLOCK_TRAIL; i--) {
if (i > 0) generator.state.history.get(i).setType(Material.AIR);
}
}
private void updateVisualTime(ParkourPlayer p, int selectedTime) {
int t = 18000 + selectedTime;
if (t >= 24000) t -= 24000;
p.player.setPlayerTime(t, false);
}
/**
* Handle player scoring.
*/
public void score() {
generator.state.score++;
generator.state.totalScore++;
checkRewards();
new ParkourScoreEvent(generator.player).call();
interactionHandler.handleScore();
}
private void checkRewards() {
if (!Rewards.REWARDS_ENABLED || generator.state.score == 0) return;
if (Rewards.SCORE_REWARDS.containsKey(generator.state.score)) {
Rewards.SCORE_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
}
int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score")
? generator.state.totalScore
: generator.state.score;
for (int interval : Rewards.INTERVAL_REWARDS.keySet()) {
if (intervalScore % interval == 0) {
Rewards.INTERVAL_REWARDS.get(interval).forEach(r -> r.execute(generator.player, generator.getMode()));
}
}
String key = Integer.toString(generator.state.score);
if (Rewards.ONE_TIME_REWARDS.containsKey(generator.state.score)
&& !generator.player.collectedRewards.contains(key)) {
Rewards.ONE_TIME_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
generator.player.collectedRewards.add(key);
}
}
/**
* Handle player falling.
*/
public void fall() {
new ParkourFallEvent(generator.player).call();
// Save ghost if score is good enough
if (ghostRecorder != null && ghostManager != null && ghostRecorder.isRecording()) {
String modeName = generator.getMode().getName();
int score = generator.state.score;
if (ghostManager.shouldRecordGhost(modeName, score)) {
// Pass UUID as well as name — GhostManager names files by UUID
GhostData ghostData = ghostRecorder.stopRecording(
generator.player.getUUID(),
generator.player.getName(),
score
);
ghostManager.saveGhost(modeName, ghostData);
generator.player.sendTranslated("ghost.saved", Integer.toString(score));
}
}
if (ghostManager != null) {
ghostManager.stopAllGhosts();
}
generator.reset(true);
interactionHandler.handleFall();
}
protected void cleanupDistantBlocks() {
if (generator.state.history.size() < Option.BLOCK_CLEANUP_DISTANCE * 2) return;
Location loc = generator.player.getLocation();
int removed = 0;
Iterator<Block> it = generator.state.history.iterator();
while (it.hasNext()) {
Block b = it.next();
if (b.getLocation().distance(loc) > Option.BLOCK_CLEANUP_DISTANCE) {
b.setType(Material.AIR, false);
it.remove();
removed++;
} else break;
}
if (removed > 0) {
generator.state.lastPositionIndexPlayer = Math.max(0, generator.state.lastPositionIndexPlayer - removed);
}
/**
* Reset the lifecycle state.
*/
public void reset(boolean regenerate) {
tickManager.reset();
cleanup.reset(regenerate);
}
}

View file

@ -0,0 +1,100 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.reward.Reward;
import dev.loki.loparkour.reward.Rewards;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
/**
* Manages lifecycle events for parkour generator.
*/
public class LifecycleEventManager {
private final ParkourGenerator generator;
public LifecycleEventManager(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Handle score event and check rewards.
*/
public void handleScore() {
// Fire score event for each player
for (ParkourPlayer player : generator.getPlayers()) {
ParkourScoreEvent scoreEvent = new ParkourScoreEvent(player);
Bukkit.getPluginManager().callEvent(scoreEvent);
}
// Check for rewards
checkRewards();
}
/**
* Handle fall event.
*/
public void handleFall() {
// Fire fall event for each player
for (ParkourPlayer player : generator.getPlayers()) {
ParkourFallEvent fallEvent = new ParkourFallEvent(player);
Bukkit.getPluginManager().callEvent(fallEvent);
}
// Process fall logic
processFall();
}
private void checkRewards() {
if (!Rewards.REWARDS_ENABLED) {
return;
}
Mode mode = generator.getMode();
for (ParkourPlayer player : generator.getPlayers()) {
int score = generator.state.score;
// Score-based rewards
if (Rewards.SCORE_REWARDS.containsKey(score)) {
for (Reward reward : Rewards.SCORE_REWARDS.get(score)) {
reward.execute(player, mode);
}
}
// Interval rewards (every N blocks)
for (Map.Entry<Integer, List<Reward>> entry : Rewards.INTERVAL_REWARDS.entrySet()) {
if (score > 0 && score % entry.getKey() == 0) {
for (Reward reward : entry.getValue()) {
reward.execute(player, mode);
}
}
}
// One-time milestone rewards
if (Rewards.ONE_TIME_REWARDS.containsKey(score)) {
for (Reward reward : Rewards.ONE_TIME_REWARDS.get(score)) {
reward.execute(player, mode);
}
}
}
}
private void processFall() {
// Record statistics
String time = generator.getFormattedTime();
String difficulty = String.valueOf(generator.getDifficultyScore());
generator.registerScore(time, difficulty, generator.state.score);
// Reset generator state
generator.reset(true);
}
}

View file

@ -0,0 +1,78 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import org.jetbrains.annotations.NotNull;
/**
* Manages tick cycle and periodic operations for generator lifecycle.
*/
public class LifecycleTickManager {
private static final int TIME_UI_INTERVAL = 20; // 1 second — time display tick
private static final int GENERATION_LEAD = 5;
private final ParkourGenerator generator;
private final GeneratorCleanup cleanup;
private final LifecycleVisualUpdater visualUpdater;
private int tickCounter = 0;
public LifecycleTickManager(
@NotNull ParkourGenerator generator,
@NotNull GeneratorCleanup cleanup,
@NotNull LifecycleVisualUpdater visualUpdater) {
this.generator = generator;
this.cleanup = cleanup;
this.visualUpdater = visualUpdater;
}
/**
* Start the tick cycle.
*/
public void startTick() {
if (generator.state.start == null) {
generator.state.start = java.time.Instant.now();
}
// Generate initial blocks
generator.generate(GENERATION_LEAD);
}
/**
* Main tick method called every server tick.
*/
public void tick() {
tickCounter++;
if (tickCounter % Option.CLEANUP_INTERVAL == 0) {
cleanup.cleanupDistantBlocks();
}
if (tickCounter % TIME_UI_INTERVAL == 0) {
visualUpdater.updateTimeDisplays();
}
// Generate more blocks if needed
maintainBlockLead();
// Update player visuals
visualUpdater.updateAllPlayers();
}
/**
* Reset tick counter.
*/
public void reset() {
tickCounter = 0;
}
private void maintainBlockLead() {
int currentBlocks = generator.state.history.size();
int targetBlocks = generator.state.score + GENERATION_LEAD;
if (currentBlocks < targetBlocks) {
int needed = targetBlocks - currentBlocks;
generator.generate(needed);
}
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
/**
* Manages visual updates for players during parkour lifecycle.
*/
public class LifecycleVisualUpdater {
private final ParkourGenerator generator;
public LifecycleVisualUpdater(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Update visuals for all players.
*/
public void updateAllPlayers() {
for (ParkourPlayer player : generator.getPlayers()) {
updatePlayerVisuals(player);
}
}
/**
* Update time displays for all players.
*/
public void updateTimeDisplays() {
for (ParkourPlayer player : generator.getPlayers()) {
updatePlayerTimeDisplay(player);
}
}
private void updatePlayerVisuals(@NotNull ParkourPlayer player) {
// Update scoreboard if enabled
if (player.showScoreboard != null && player.showScoreboard) {
player.updateScoreboard(generator);
}
// Update action bar with time and score
updatePlayerTimeDisplay(player);
}
@SuppressWarnings("unused") // reserved for action bar
private void updatePlayerTimeDisplay(@NotNull ParkourPlayer player) {
// Placeholder for action bar (Paper adventure); scoreboard already shows time/score.
}
}

View file

@ -0,0 +1,103 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Handles player interactions with parkour blocks.
*/
public class PlayerInteractionHandler {
private static final int GENERATION_LEAD = 5;
private final ParkourGenerator generator;
private final LifecycleEventManager eventManager;
private final GeneratorCleanup cleanup;
public PlayerInteractionHandler(
@NotNull ParkourGenerator generator,
@NotNull LifecycleEventManager eventManager,
@NotNull GeneratorCleanup cleanup) {
this.generator = generator;
this.eventManager = eventManager;
this.cleanup = cleanup;
}
/**
* Check all players for block interactions.
*/
public void checkAllPlayers() {
for (ParkourPlayer player : generator.getPlayers()) {
Block below = getBlockBelow(player);
if (below != null) {
handleBlockInteraction(player, below);
}
}
}
/**
* Handle player scoring on a block.
*/
public void handleScore() {
generator.state.score++;
eventManager.handleScore();
// Generate more blocks after scoring
generator.generate(1);
}
/**
* Handle player falling.
*/
public void handleFall() {
eventManager.handleFall();
}
private void handleBlockInteraction(@NotNull ParkourPlayer player, @NotNull Block block) {
// Check if it's a schematic end block
if (isSchematicEndBlock(block)) {
handleSchematicEndBlock(block);
}
// Check for scoring
if (generator.state.history.contains(block) && !player.hasScored(block)) {
player.markScored(block);
handleScore();
}
}
private void handleSchematicEndBlock(@NotNull Block block) {
// Remove schematic blocks and continue with normal generation
if (generator.state.schematicBlocks != null) {
cleanup.removeTrailBlocks(0); // Remove schematic trail
generator.state.schematicBlocks = null;
}
// Continue normal generation
generator.generate(GENERATION_LEAD);
}
private boolean isSchematicEndBlock(@NotNull Block block) {
LPSchematic schematic = generator.island.schematic;
if (schematic == null) return false;
// Simplified check - would need full schematic logic
return generator.state.schematicBlocks != null &&
generator.state.schematicBlocks.contains(block);
}
/** Block directly under feet; {@code null} in air — scoring runs when landed. */
@Nullable
private Block getBlockBelow(@NotNull ParkourPlayer player) {
Location loc = player.player.getLocation().clone();
Block below = loc.subtract(0, 1, 0).getBlock();
return below.getType() != Material.AIR ? below : null;
}
}

View file

@ -6,8 +6,6 @@ import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.RegisteredServiceProvider;
import java.util.NoSuchElementException;
public class VaultHook {
private static Economy economy;

View file

@ -1,9 +1,5 @@
package dev.loki.loparkour.leaderboard;
import java.util.ArrayList;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.storage.Storage;
@ -29,9 +25,9 @@ public class Leaderboard {
public final Sort sort;
/**
* A map of all scores for this mode
* A map of all scores for this mode (thread-safe)
*/
public final Map<UUID, Score> scores = new LinkedHashMap<>();
public final Map<UUID, Score> scores = Collections.synchronizedMap(new LinkedHashMap<>());
public Leaderboard(@NotNull String mode, Sort sort) {
this.mode = mode.toLowerCase();
@ -45,9 +41,8 @@ public class Leaderboard {
var interval = Config.CONFIG.getInt("storage-update-interval");
// TODO: Fix scheduler call - needs proper implementation
// read/write all data every x seconds after x seconds to allow time for reading/writing
Scheduler.get(LoParkour.getPlugin()).runTimerAsync(() -> {
// Main-thread timer: only schedules I/O; avoids async + Bukkit edge cases
Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
if (Config.CONFIG.getBoolean("joining")) {
write(true);
} else {
@ -68,8 +63,13 @@ public class Leaderboard {
*/
public void read(boolean async) {
run(() -> {
scores.clear();
scores.putAll(Storage.readScores(mode));
Map<UUID, Score> loadedScores = Storage.readScores(mode);
// Synchronize access to prevent concurrent modification
synchronized (scores) {
scores.clear();
scores.putAll(loadedScores);
}
sort();
}, async);
@ -91,7 +91,12 @@ public class Leaderboard {
public Map<UUID, Score> sort(Sort sort) {
LinkedHashMap<UUID, Score> sorted = new LinkedHashMap<>();
scores.entrySet().stream()
List<Map.Entry<UUID, Score>> snapshot;
synchronized (scores) {
snapshot = new ArrayList<>(scores.entrySet());
}
snapshot.stream()
.sorted((one, two) -> {
switch (sort) {
case SCORE -> {
@ -122,8 +127,11 @@ public class Leaderboard {
private void sort() {
var sorted = sort(sort);
scores.clear();
scores.putAll(sorted);
// Synchronize access to prevent concurrent modification
synchronized (scores) {
scores.clear();
scores.putAll(sorted);
}
}
/**
@ -157,7 +165,11 @@ public class Leaderboard {
* Resets all registered scores for this mode
*/
public void resetAll() {
new HashSet<>(scores.keySet()).forEach(this::remove);
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
keys.forEach(this::remove);
}
/**
@ -174,7 +186,11 @@ public class Leaderboard {
* @return The rank. Starts from 1. Returns 0 if no ranking is found.
*/
public int getRank(@NotNull UUID uuid) {
return new ArrayList<>(scores.keySet()).indexOf(uuid) + 1;
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
return keys.indexOf(uuid) + 1;
}
/**
@ -186,11 +202,14 @@ public class Leaderboard {
*/
@Nullable
public Score getScoreAtRank(int rank) {
if (scores.size() < rank) {
return null;
List<Score> values;
synchronized (scores) {
if (scores.size() < rank) {
return null;
}
values = new ArrayList<>(scores.values());
}
return new ArrayList<>(scores.values()).get(rank - 1);
return values.get(rank - 1);
}
public enum Sort {

View file

@ -83,14 +83,14 @@ public class ParkourRestrictionListener implements Listener {
&& event.getHand() == org.bukkit.inventory.EquipmentSlot.HAND;
if (!isRightClick) return;
org.bukkit.Material held = heldItem(player).getType();
ItemStack held = heldItem(player);
event.setCancelled(true);
if (held == Locales.getItem(player, "play.item").build().getType()) Menus.PLAY.open(player);
else if (held == Locales.getItem(player, "community.item").build().getType()) Menus.COMMUNITY.open(player);
else if (held == Locales.getItem(player, "settings.item").build().getType()) Menus.SETTINGS.open(player);
else if (held == Locales.getItem(player, "lobby.item").build().getType()) Menus.LOBBY.open(player);
else if (held == Locales.getItem(player, "other.quit").build().getType()) ParkourUser.leave(player);
if (matchesLocaleHotbar(player, "play.item", held)) Menus.PLAY.open(player);
else if (matchesLocaleHotbar(player, "community.item", held)) Menus.COMMUNITY.open(player);
else if (matchesLocaleHotbar(player, "settings.item", held)) Menus.SETTINGS.open(player);
else if (matchesLocaleHotbar(player, "lobby.item", held)) Menus.LOBBY.open(player);
else if (matchesLocaleHotbar(player, "other.quit", held)) ParkourUser.leave(player);
else if (!Config.CONFIG.getBoolean("options.disable-inventory-blocks")) event.setCancelled(false);
}
@ -130,4 +130,10 @@ public class ParkourRestrictionListener implements Listener {
? inv.getItemInOffHand()
: inv.getItemInMainHand();
}
/** Matches configured hotbar item (material + meta), not material alone. */
private boolean matchesLocaleHotbar(Player player, String localeKey, ItemStack held) {
ItemStack ref = Locales.getItem(player, localeKey).build();
return held != null && held.getType() != org.bukkit.Material.AIR && held.isSimilar(ref);
}
}

View file

@ -27,7 +27,7 @@ public abstract class DynamicMenu {
* @param item The item
* @param shouldDisplay Whether this item should be displayed right now.
*/
// TODO: Migrate to LoLib GUI system
// Stub: migrate to LoLib GUI when dynamic rows are wired up.
/*
public void registerMainItem(int row, int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.MenuItem> item, Predicate<Player> shouldDisplay) {
if (id < 0 || row < 0 || row > 4) {
@ -50,7 +50,7 @@ public abstract class DynamicMenu {
* @param player The player
* @param menu The menu
*/
// TODO: Migrate to LoLib GUI system
// Stub: migrate to LoLib GUI when dynamic rows are wired up.
/*
public void display(Player player, Menu menu) {
ParkourUser user = ParkourUser.getUser(player);

View file

@ -26,7 +26,8 @@ public abstract class LPMenu {
protected String locale(@NotNull Player player) {
var user = dev.loki.loparkour.player.ParkourUser.getUser(player);
return user != null ? user.locale : Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
String loc = user != null ? user.locale : Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
return (loc != null && !loc.isBlank()) ? loc : "en";
}
protected ItemStack filler() {

View file

@ -3,7 +3,6 @@ package dev.loki.loparkour.menu;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.ColorUtil;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;

View file

@ -1,34 +1,8 @@
package dev.loki.loparkour.menu;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.entity.Player;
/**
* Temporary stub for menu system during migration
* TODO: Implement proper menu system using LoLib GUI or custom implementation
* Placeholder during menu migration; real flows use {@link Menus} / LoLib GUI.
*/
public class MenuStub {
// TODO: Migrate to LoLib GUI system
/*
public void open(Player player) {
player.sendMessage("§cМеню временно недоступно во время миграции");
}
*/
// TODO: Migrate to LoLib GUI system
/*
public void open(Player player, Mode mode, Leaderboard.Sort sort) {
player.sendMessage("§cМеню временно недоступно во время миграции");
}
*/
// TODO: Migrate to LoLib GUI system
/*
public void open(ParkourPlayer player) {
player.player.sendMessage("§cМеню временно недоступно во время миграции");
}
*/
public final class MenuStub {
private MenuStub() {}
}

View file

@ -7,8 +7,7 @@ import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.util.ColorUtil;
import dev.lolib.gui.ScrollableGUI;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
@ -18,8 +17,16 @@ import java.util.List;
public class LeaderboardsMenu extends LPMenu {
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
private int currentPage = 0;
@Override
public void open(@NotNull Player player) {
open(player, 0);
}
public void open(@NotNull Player player, int page) {
this.currentPage = page;
String locale = locale(player);
String title = Locales.getString(locale, ParkourOption.LEADERBOARDS.path + ".name");
@ -39,18 +46,32 @@ public class LeaderboardsMenu extends LPMenu {
return;
}
ScrollableGUI gui = ScrollableGUI.create()
.title(ColorUtil.color(title))
.rows(3)
.scrollUpButton(backItem(), 0)
.scrollDownButton(nextItem(), 8);
// Calculate pagination
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
for (int i = 0; i < items.size(); i++) {
InventoryGUI gui = baseGui(title, 3);
// Add items for current page
for (int i = startIndex; i < endIndex; i++) {
final Mode mode = modes.get(i);
gui = gui.addItem(items.get(i),
int slot = 10 + (i - startIndex); // Slots 10-16
gui = gui.setItem(slot, items.get(i),
e -> Menus.SINGLE_LEADERBOARD.open(player, mode, Leaderboard.Sort.SCORE));
}
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
}
gui.open(player);
}
}

View file

@ -7,8 +7,7 @@ import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.ColorUtil;
import dev.lolib.gui.ScrollableGUI;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
@ -24,7 +23,15 @@ import java.util.UUID;
public class SingleLeaderboardMenu extends LPMenu {
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
private int currentPage = 0;
public void open(@NotNull Player player, @NotNull Mode mode, @NotNull Leaderboard.Sort sort) {
open(player, mode, sort, 0);
}
public void open(@NotNull Player player, @NotNull Mode mode, @NotNull Leaderboard.Sort sort, int page) {
this.currentPage = page;
Leaderboard leaderboard = mode.getLeaderboard();
if (leaderboard == null) return;
@ -66,14 +73,28 @@ public class SingleLeaderboardMenu extends LPMenu {
items.add(skull);
}
ScrollableGUI gui = ScrollableGUI.create()
.title(ColorUtil.color(title))
.rows(3)
.scrollUpButton(backItem(), 0)
.scrollDownButton(nextItem(), 8);
// Calculate pagination
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
for (ItemStack item : items) {
gui = gui.addItem(item, e -> { /* view only */ });
InventoryGUI gui = baseGui(title, 3);
// Add items for current page
for (int i = startIndex; i < endIndex; i++) {
int slot = 10 + (i - startIndex); // Slots 10-16
gui = gui.setItem(slot, items.get(i), e -> { /* view only */ });
}
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, mode, sort, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, mode, sort, currentPage + 1));
}
gui.open(player);

View file

@ -4,7 +4,6 @@ import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;

View file

@ -5,23 +5,27 @@ import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.MultiMode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.ColorUtil;
import dev.lolib.gui.ScrollableGUI;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class SingleMenu extends LPMenu {
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
private int currentPage = 0;
@Override
public void open(@NotNull Player player) {
open(player, 0);
}
public void open(@NotNull Player player, int page) {
this.currentPage = page;
String locale = locale(player);
List<Mode> availableModes = new ArrayList<>();
@ -32,7 +36,7 @@ public class SingleMenu extends LPMenu {
if (mode.getName().equals("spectator")) continue;
// Check if mode is enabled in config
if (!Config.CONFIG.getBoolean("modes." + mode.getName() + ".enabled")) continue;
if (!Config.CONFIG.getBoolean("modes." + mode.getName() + ".enabled", true)) continue;
boolean blocked = Config.CONFIG.getBoolean("permissions.enabled")
&& !player.hasPermission("LoParkour.gamemode." + mode.getName());
@ -52,15 +56,18 @@ public class SingleMenu extends LPMenu {
String title = Locales.getString(locale, "play.single.name");
ScrollableGUI gui = ScrollableGUI.create()
.title(ColorUtil.color(title))
.rows(3)
.scrollUpButton(backItem(), 0)
.scrollDownButton(nextItem(), 8);
// Calculate pagination
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
for (int i = 0; i < items.size(); i++) {
InventoryGUI gui = baseGui(title, 3);
// Add items for current page
for (int i = startIndex; i < endIndex; i++) {
final Mode mode = availableModes.get(i);
gui = gui.addItem(items.get(i), e -> {
int slot = 10 + (i - startIndex); // Slots 10-16
gui = gui.setItem(slot, items.get(i), e -> {
// Prevent multiple clicks - check if player is already in parkour
if (ParkourUser.getUser(player) != null) {
return; // Already in parkour, ignore click
@ -71,6 +78,17 @@ public class SingleMenu extends LPMenu {
});
}
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
}
gui.open(player);
}
}

View file

@ -5,9 +5,8 @@ import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.world.Divider;
import dev.lolib.gui.ScrollableGUI;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
@ -19,8 +18,16 @@ import java.util.List;
public class SpectatorMenu extends LPMenu {
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
private int currentPage = 0;
@Override
public void open(@NotNull Player player) {
open(player, 0);
}
public void open(@NotNull Player player, int page) {
this.currentPage = page;
String locale = locale(player);
ParkourUser user = ParkourUser.getUser(player);
String title = Locales.getString(locale, "play.spectator.name");
@ -44,15 +51,29 @@ public class SpectatorMenu extends LPMenu {
items.add(skull);
}
ScrollableGUI gui = ScrollableGUI.create()
.title(ColorUtil.color(title))
.rows(3)
.scrollUpButton(backItem(), 0)
.scrollDownButton(nextItem(), 8);
// Calculate pagination
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
for (int i = 0; i < items.size(); i++) {
InventoryGUI gui = baseGui(title, 3);
// Add items for current page
for (int i = startIndex; i < endIndex; i++) {
final Session session = sessions.get(i);
gui = gui.addItem(items.get(i), e -> Modes.SPECTATOR.create(player, session));
int slot = 10 + (i - startIndex); // Slots 10-16
gui = gui.setItem(slot, items.get(i), e -> Modes.SPECTATOR.create(player, session));
}
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
}
gui.open(player);

View file

@ -30,7 +30,7 @@ public class LangMenu extends LPMenu {
String locale = pp.locale;
String title = Locales.getString(locale, "settings.lang.name");
List<String> langs = new ArrayList<>(Locales.locales.keySet());
List<String> langs = new ArrayList<>(Locales.getLocaleKeys());
InventoryGUI gui = baseGui(title, 3);

View file

@ -147,7 +147,6 @@ public class ParkourSettingsMenu extends LPMenu {
String locale = pp.locale;
String title = Locales.getString(locale, "settings.parkour_settings.styles.name");
List<Style> styles = new ArrayList<>();
InventoryGUI gui = baseGui(title, 3);
int slot = 10;

View file

@ -1,14 +1,12 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
@ -139,8 +137,10 @@ public class CoopMode implements MultiMode {
protected void score() {
super.score(); // increments state.score and totalScore
// Track which player was on the block (primary player for single, varies in multi)
contributions.merge(player.getUUID(), 1, Integer::sum);
// Track contributions for all players in session
for (ParkourPlayer pp : getPlayers()) {
contributions.merge(pp.getUUID(), 1, Integer::sum);
}
// Milestone every 50 points
if (state.score % 50 == 0) {

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;

View file

@ -1,19 +1,12 @@
package dev.loki.loparkour.mode;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.elytra.*;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import dev.loki.loparkour.util.ColorUtil;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
@ -23,730 +16,231 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Generator for Elytra mode.
*
* <p>Completely replaces block-based parkour with an aerial ring course:
* <ul>
* <li>Rings are placed ahead along a smooth curved path in the sky</li>
* <li>Each ring is vertical, perpendicular to the flight direction</li>
* <li>Player must fly through the center — detection uses plane-crossing math</li>
* <li>Firework usage is intercepted via event to enforce cooldown properly</li>
* <li>Fall = player touches ground / loses flight / flies too far from course</li>
* </ul>
* Refactored Elytra mode generator using composition pattern.
* Coordinates ring generation, physics, and rendering components.
*/
public class ElytraGenerator extends ParkourGenerator implements Listener {
// ── Constants ─────────────────────────────────────────────────────────────
/** How many rings behind the player to keep before deleting. */
private static final int RING_TRAIL = 3;
/** Ticks between particle ring renders (every 2 ticks = 10fps). */
private static final int RENDER_INTERVAL = 2;
/** Minimum Y height — if player drops below this they failed. */
private static final double MIN_Y_OFFSET = -30.0;
// Config-driven values cached at init
private int ringLead;
private double maxDeviation;
/** Particle count per ring point. */
private static final int PARTICLES_PER_POINT = 1;
/** Number of particle points around each ring. */
private static final int RING_POINTS = 36;
// ── State ─────────────────────────────────────────────────────────────────
private final ElytraConfig config;
private final ElytraRingGenerator ringGenerator;
private final ElytraPhysics physics;
private final ElytraRenderer renderer;
private final List<ElytraRing> rings = new ArrayList<>();
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
private int tickCounter = 0;
private Instant startTime = null;
private int score = 0;
private ScheduledTask tickTask;
private ScheduledTask renderTask;
/** Firework cooldown per player UUID in ms. */
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
/** Starting Y for fall detection. */
private double spawnY;
// ── Constructor ───────────────────────────────────────────────────────────
private final Map<ParkourPlayer, Integer> playerRingIndex = new HashMap<>();
private BukkitTask renderTask;
private BukkitTask physicsTask;
public ElytraGenerator(@NotNull Session session) {
super(session);
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
this.config = new ElytraConfig();
this.ringGenerator = new ElytraRingGenerator(config);
this.physics = new ElytraPhysics(config);
this.renderer = new ElytraRenderer();
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
}
// ── Mode identification ───────────────────────────────────────────────────
@Override
public Mode getMode() {
return Modes.ELYTRA;
}
// ── Lifecycle: called by Session after island.build() ────────────────────
/**
* Called once when the generator is set up.
* We skip the block-based generateFirst and do our own aerial setup.
*/
@Override
public void generateFirst(Location spawn, Location blockSpawn) {
spawnY = spawn.getY();
state.playerSpawn = spawn;
state.lastStandingPlayerLocation = spawn;
state.blockSpawn = blockSpawn;
// Seed the ring path starting just in front of spawn
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Setup player
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
p.setGameMode(GameMode.ADVENTURE);
p.getInventory().clear();
// Elytra chestplate
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
// Starting fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
p.getInventory().setItem(0, makeFirework(amount));
p.getInventory().setHeldItemSlot(0);
// Launch into glide
p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
p.setGliding(true);
pp.sendTranslated("modes.elytra.start_hint");
// Generate initial rings
Vector initialDirection = new Vector(1, 0, 0); // East direction
List<ElytraRing> initialRings = ringGenerator.generateRings(spawn, initialDirection, config.getRingLead());
rings.addAll(initialRings);
// Initialize player data
for (ParkourPlayer player : getPlayers()) {
playerRingIndex.put(player, 0);
giveElytraAndFireworks(player);
}
// Register firework event listener
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
// Start ticks
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1);
renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL);
startTasks();
}
// ── generateFirst is the entry — skip normal generate() completely ────────
@Override
public void generate() { /* not used in elytra mode */ }
@Override
public void generate(int amount) { /* not used */ }
@Override
public void startTick() { /* tick started in generateFirst */ }
// ── Main tick ─────────────────────────────────────────────────────────────
@Override
public void tick() {
if (state.stopped) {
stopTasks();
return;
}
tickCounter++;
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
// Ensure player stays gliding
if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) {
// Only force-glide if they're not on ground and have elytra
if (!p.isOnGround()) {
p.setGliding(true);
}
}
// Update scoreboard
pp.updateScoreboard(this);
p.setSaturation(20);
// Action bar: score + next ring distance
updateActionBar(pp);
}
// Fall detection
checkFall();
// Ring crossing detection
checkRingCrossings();
super.tick();
checkRingsAndPhysics();
generateMoreRings();
cleanupOldRings();
}
// ── Ring generation ───────────────────────────────────────────────────────
/**
* Generates {@code count} new rings starting from {@code origin},
* heading in {@code direction}, with smooth curve randomisation.
*/
private void generateRings(Location origin, Vector direction, int count) {
int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min");
int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max");
double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
ThreadLocalRandom rng = ThreadLocalRandom.current();
Location cursor = origin.clone();
Vector heading = direction.clone().normalize();
// If we already have rings, continue from the last one
if (!rings.isEmpty()) {
ElytraRing last = rings.get(rings.size() - 1);
cursor = last.center.clone();
heading = last.normal.clone();
}
for (int i = 0; i < count; i++) {
// Distance between rings
double dist = distMin + rng.nextDouble() * (distMax - distMin);
// Smooth heading change using config-defined limits
double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw));
double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch));
heading = rotateVector(heading, yawDelta, pitchDelta);
heading.normalize();
// New ring center
Location center = cursor.clone().add(heading.clone().multiply(dist));
// Keep rings in a reasonable Y band
double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY()));
center.setY(targetY);
rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize));
cursor = center;
}
}
// ── Ring crossing detection ───────────────────────────────────────────────
private void checkRingCrossings() {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ParkourPlayer pp = player; // primary player
Location loc = pp.getLocation();
ElytraRing nextRing = rings.get(nextRingIndex);
// Check if player crossed the ring's plane
if (nextRing.hasCrossed(loc, pp.player.getVelocity())) {
if (nextRing.isInside(loc)) {
// Perfect pass — score
onRingPassed(pp, nextRing, true);
} else {
// Crossed the plane but missed the ring hole
onRingMissed(pp, nextRing);
}
nextRingIndex++;
// Generate more rings ahead
int ahead = rings.size() - nextRingIndex;
if (ahead < ringLead) {
generateRings(null, null, ringLead - ahead);
}
// Clean up old rings
cleanOldRings();
}
// Check deviation — if player flew too far off course
double deviation = nextRing.center.distance(loc);
if (deviation > maxDeviation) {
triggerFall("flew off course");
}
}
private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) {
score++;
if (startTime == null) startTime = Instant.now();
new ParkourScoreEvent(pp).call();
// Visual + sound feedback
pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15);
pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f);
// Update state score so scoreboard works
state.score = score;
state.totalScore++;
}
private void onRingMissed(ParkourPlayer pp, ElytraRing ring) {
// Flash red to indicate miss — no score
pp.player.spawnParticle(Particle.REDSTONE,
ring.center, 20, 0.5, 0.5, 0.5, 0,
new Particle.DustOptions(Color.RED, 2.0f));
pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f);
}
// ── Fall / Reset ──────────────────────────────────────────────────────────
private void checkFall() {
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
double y = p.getLocation().getY();
// Touched ground or fell too far below spawn
if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) {
triggerFall("touched ground or fell below course");
return;
}
}
}
private void triggerFall(String reason) {
LoParkour.log("ElytraGenerator fall: " + reason);
new ParkourFallEvent(player).call();
// Save score to leaderboard if it's a new record
Leaderboard lb = getMode().getLeaderboard();
if (lb != null) {
int record = lb.get(player.getUUID()).score();
if (score > record) {
String time = getFormattedTime();
getPlayers().forEach(p -> lb.put(p.getUUID(),
new Score(p.getName(), time, "0.0", score)));
}
}
// Show fall message
sendFallMessage();
reset(true);
}
private void sendFallMessage() {
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
for (ParkourPlayer pp : getPlayers()) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
if (score > record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat",
Integer.toString(score - record));
} else if (score == record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0");
} else {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss",
Integer.toString(record - score));
}
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
}
@Override
public void reset(boolean regenerate) {
state.stopped = !regenerate;
// Clear rings from world (remove barrier blocks)
rings.forEach(ElytraRing::remove);
rings.clear();
nextRingIndex = 0;
score = 0;
state.score = 0;
startTime = null;
state.start = null;
tickCounter = 0;
fireworkCooldowns.clear();
if (!regenerate) {
stopTasks();
unregisterListener();
island.destroy();
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
cleanup(); // Call cleanup BEFORE super.reset to cancel tasks first
super.reset(regenerate);
}
private void checkRingsAndPhysics() {
for (ParkourPlayer player : getPlayers()) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
// Check ring crossings
int newIndex = physics.checkRingCrossings(player, rings, currentIndex);
if (newIndex > currentIndex) {
onRingPassed(player, rings.get(newIndex - 1));
playerRingIndex.put(player, newIndex);
}
// Check for falls
ElytraPhysics.FallCheckResult fallResult = physics.checkFall(player, rings, currentIndex);
if (fallResult.shouldFall()) {
triggerFall(player, fallResult.getReason());
}
updateActionBar(player);
}
}
private void generateMoreRings() {
if (rings.isEmpty()) {
return;
}
// Teleport back to spawn and restart
for (ParkourPlayer pp : getPlayers()) {
pp.teleport(state.playerSpawn);
pp.player.setGliding(false);
pp.player.setVelocity(new Vector(0, 0, 0));
// Restore fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
pp.player.getInventory().clear();
pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
pp.player.getInventory().setItem(0, makeFirework(amount));
pp.player.getInventory().setHeldItemSlot(0);
}
// Re-generate rings
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = state.playerSpawn.clone()
.add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Brief delay then launch
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
for (ParkourPlayer pp : getPlayers()) {
pp.player.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
pp.player.setGliding(true);
}
}, 10L);
}
// ── Ring rendering ────────────────────────────────────────────────────────
private void renderRings() {
if (state.stopped) return;
Location playerLoc = player.getLocation();
for (int i = nextRingIndex; i < Math.min(nextRingIndex + ringLead, rings.size()); i++) {
ElytraRing ring = rings.get(i);
double dist = ring.center.distance(playerLoc);
if (dist > 80) continue; // Don't render very far rings
// Colour gradient: next ring = gold, further = white, collected = skip
Color color = (i == nextRingIndex) ? Color.YELLOW
: (i == nextRingIndex + 1) ? Color.ORANGE
: Color.WHITE;
renderRing(ring, color);
if (rings.size() < config.getRingLead() * 2) {
ElytraRing lastRing = rings.get(rings.size() - 1);
Vector direction = calculateNextDirection(lastRing);
List<ElytraRing> newRings = ringGenerator.generateRings(
lastRing.getCenter(), direction, config.getRingLead()
);
rings.addAll(newRings);
}
}
private void renderRing(ElytraRing ring, Color color) {
World world = ring.center.getWorld();
if (world == null) return;
// Two orthogonal vectors in the ring's plane
Vector normal = ring.normal.clone().normalize();
Vector up = findPerpendicular(normal);
Vector right = normal.clone().crossProduct(up).normalize();
double r = ring.radius;
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
for (int i = 0; i < RING_POINTS; i++) {
double angle = 2 * Math.PI * i / RING_POINTS;
double x = r * Math.cos(angle);
double y = r * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust);
}
// Inner ring (smaller, brighter) so it's visible
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
double innerR = r * 0.4;
for (int i = 0; i < RING_POINTS / 2; i++) {
double angle = 2 * Math.PI * i / (RING_POINTS / 2);
double x = innerR * Math.cos(angle);
double y = innerR * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust);
}
private void onRingPassed(@NotNull ParkourPlayer player, @NotNull ElytraRing ring) {
boolean centered = physics.isPlayerCentered(player.player.getLocation(), ring);
int points = centered ? 10 : 5;
player.session.generator.state.score += points;
// Play sound and show message
player.player.playSound(player.player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
String message = centered ? "Perfect! +" + points : "Good! +" + points;
player.player.sendMessage(ColorUtil.color("&a" + message));
}
// ── Firework event — cancel if on cooldown ────────────────────────────────
@EventHandler(priority = EventPriority.HIGH)
public void onFireworkUse(PlayerInteractEvent event) {
Player p = event.getPlayer();
ParkourPlayer pp = ParkourPlayer.getPlayer(p);
if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return;
if (gen != this) return;
// Only care about firework rockets
org.bukkit.inventory.ItemStack item = event.getItem();
if (item == null || item.getType() != Material.FIREWORK_ROCKET) return;
boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK;
if (!isRightClick) return;
if (!p.isGliding()) return;
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
UUID uuid = p.getUniqueId();
Long lastUse = fireworkCooldowns.get(uuid);
if (lastUse != null && now - lastUse < cooldownMs) {
event.setCancelled(true);
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
p.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§c⏱ Firework cooldown: §f" + remaining + "s"));
return;
}
fireworkCooldowns.put(uuid, now);
// Spawn a boost firework at player location instead of vanilla behaviour
event.setCancelled(true);
spawnBoostFirework(p);
private void triggerFall(@NotNull ParkourPlayer player, @NotNull String reason) {
fall();
player.player.sendMessage(ColorUtil.color("&cFell: " + reason));
}
/**
* Spawns a visual-only firework that gives the player a velocity boost.
* We do it manually so we control the power and remove the damage.
*/
private void spawnBoostFirework(Player p) {
double power = Config.CONFIG.getDouble("modes.elytra.boost-power");
Vector dir = p.getLocation().getDirection().normalize();
// Remove one firework from inventory
ItemStack held = p.getInventory().getItemInMainHand();
if (held.getType() == Material.FIREWORK_ROCKET) {
if (held.getAmount() > 1) {
held.setAmount(held.getAmount() - 1);
} else {
p.getInventory().setItemInMainHand(null);
}
}
// Apply boost velocity
Vector boost = dir.multiply(power);
p.setVelocity(p.getVelocity().add(boost));
p.setGliding(true);
// Spawn visual firework that instantly explodes (no damage)
Firework fw = p.getWorld().spawn(p.getLocation(), Firework.class);
FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.STAR)
.withColor(Color.AQUA, Color.WHITE)
.withFade(Color.YELLOW)
.withTrail()
.build());
meta.setPower(0);
fw.setFireworkMeta(meta);
fw.setShotAtAngle(true);
// Detonate immediately (no damage tick)
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (fw.isValid()) fw.detonate();
}, 1L);
p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 0.8f, 1.2f);
}
// ── Action bar ────────────────────────────────────────────────────────────
private void updateActionBar(ParkourPlayer pp) {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ElytraRing nextRing = rings.get(nextRingIndex);
double dist = Math.round(nextRing.center.distance(pp.getLocation()) * 10.0) / 10.0;
// Firework cooldown indicator
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
Long lastUse = fireworkCooldowns.get(pp.getUUID());
String cooldownStr;
if (lastUse != null && now - lastUse < cooldownMs) {
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
cooldownStr = "§c⏱ " + remaining + "s";
} else {
cooldownStr = "§a✔ Ready";
}
pp.player.spigot().sendMessage(
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
private void updateActionBar(@NotNull ParkourPlayer player) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
int totalRings = rings.size();
int score = state.score;
String message = String.format("&eRing: %d/%d &7| &aScore: %d",
currentIndex + 1, totalRings, score);
player.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§6⬤ §fRings: §e" + score
+ " §7| §f➤ §b" + dist + "m"
+ " §7| §f🚀 " + cooldownStr));
}
// ── Time helpers ──────────────────────────────────────────────────────────
@Override
public String getFormattedTime() {
if (startTime == null) return "00:00:000";
var ms = Instant.now().minusMillis(startTime.toEpochMilli());
try {
return DateTimeFormatter.ofPattern("mm:ss:SSS").withZone(ZoneOffset.UTC).format(ms);
} catch (Exception e) {
return "00:00:000";
}
}
// ── Cleanup ───────────────────────────────────────────────────────────────
private void cleanOldRings() {
// Remove rings more than RING_TRAIL behind the player
int removeUntil = nextRingIndex - RING_TRAIL;
if (removeUntil <= 0) return;
Iterator<ElytraRing> it = rings.iterator();
int idx = 0;
while (it.hasNext()) {
ElytraRing ring = it.next();
if (idx < removeUntil) {
ring.remove();
it.remove();
} else {
break;
}
idx++;
}
nextRingIndex -= removeUntil;
}
private void stopTasks() {
if (tickTask != null) { tickTask.cancel(); tickTask = null; }
if (renderTask != null) { renderTask.cancel(); renderTask = null; }
}
private void unregisterListener() {
HandlerList.unregisterAll(this);
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Returns a vector perpendicular to the given one.
* Used to form the ring's local coordinate axes.
*/
private static Vector findPerpendicular(Vector v) {
Vector perp = (Math.abs(v.getX()) < 0.9)
? new Vector(1, 0, 0)
: new Vector(0, 1, 0);
return perp.subtract(v.clone().multiply(perp.dot(v))).normalize();
}
/**
* Rotates a vector by yaw (horizontal) and pitch (vertical) deltas.
*/
private static Vector rotateVector(Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cos = Math.cos(yawRad);
double sin = Math.sin(yawRad);
double nx = v.getX() * cos - v.getZ() * sin;
double nz = v.getX() * sin + v.getZ() * cos;
v = new Vector(nx, v.getY(), nz);
// Rotate around the local right axis (pitch)
Vector right = new Vector(v.getZ(), 0, -v.getX()).normalize();
double c = Math.cos(pitchRad);
double s = Math.sin(pitchRad);
double vDotR = v.dot(right);
return new Vector(
v.getX() * c + (right.getY() * v.getZ() - right.getZ() * v.getY()) * s,
v.getY() * c + (right.getZ() * v.getX() - right.getX() * v.getZ()) * s,
v.getZ() * c + (right.getX() * v.getY() - right.getY() * v.getX()) * s
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(ColorUtil.color(message))
);
}
private static ItemStack makeFirework(int amount) {
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
if (meta != null) {
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA)
.build());
meta.setPower(1);
item.setItemMeta(meta);
}
return item;
}
// ── Inner class: ElytraRing ───────────────────────────────────────────────
/**
* Represents one ring in the course.
*
* <p>A ring is defined by its center point and its normal vector (the direction
* of travel through it). The "inside" is a circle of {@code radius} around the center
* on the plane perpendicular to {@code normal}.
*/
public static class ElytraRing {
public final int id;
public final Location center;
/** Direction of travel — the ring faces perpendicular to this. */
public final Vector normal;
public final double radius;
/** Signed distance of the player on the last tick (for plane crossing detection). */
private double lastSignedDist = Double.NaN;
/** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */
private final List<Block> barrierBlocks = new ArrayList<>();
public ElytraRing(int id, Location center, Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Returns true if the player crossed the ring's plane since the last call.
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
* Sign change = crossing.
*/
public boolean hasCrossed(Location playerLoc, Vector velocity) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double signedDist = toPlayer.dot(normal);
boolean crossed = false;
if (!Double.isNaN(lastSignedDist)) {
// Crossed if sign flipped (was positive, now negative or vice versa)
crossed = (lastSignedDist > 0) != (signedDist > 0);
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
Player player = event.getPlayer();
ParkourPlayer pp = findParkourPlayer(player);
if (pp == null) return;
ItemStack item = event.getItem();
if (item != null && item.getType() == Material.FIREWORK_ROCKET) {
if (physics.applyFireworkBoost(pp)) {
spawnBoostFirework(player);
item.setAmount(item.getAmount() - 1);
}
lastSignedDist = signedDist;
return crossed;
}
/**
* Returns true if the player's position (projected onto the ring's plane)
* is within the ring's radius — i.e. they flew through the hole.
*/
public boolean isInside(Location playerLoc) {
// Project player position onto ring's plane
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
// Remove the component along the normal
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projected.length() <= radius;
}
/** Remove any barrier blocks this ring placed in the world. */
public void remove() {
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
barrierBlocks.clear();
event.setCancelled(true);
}
}
}
private void startTasks() {
renderTask = Bukkit.getScheduler().runTaskTimer(LoParkour.getPlugin(), () -> {
for (ParkourPlayer player : getPlayers()) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
renderer.renderRings(rings, currentIndex);
}
}, 0L, 2L); // Every 2 ticks
}
private void cleanup() {
// Cancel and nullify tasks to prevent memory leaks
if (renderTask != null) {
renderTask.cancel();
renderTask = null;
}
if (physicsTask != null) {
physicsTask.cancel();
physicsTask = null;
}
// Unregister event listener to prevent memory leaks
HandlerList.unregisterAll(this);
// Clear all data structures
rings.forEach(ElytraRing::remove);
rings.clear();
playerRingIndex.clear();
for (ParkourPlayer player : getPlayers()) {
physics.cleanup(player.player.getUniqueId());
}
}
private void giveElytraAndFireworks(@NotNull ParkourPlayer player) {
Player p = player.player;
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
p.getInventory().addItem(makeFirework(config.getStartingFireworks()));
}
private void spawnBoostFirework(@NotNull Player player) {
Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class);
FireworkMeta meta = firework.getFireworkMeta();
meta.setPower(1);
firework.setFireworkMeta(meta);
firework.detonate();
}
@NotNull
private Vector calculateNextDirection(@NotNull ElytraRing lastRing) {
// Simple forward direction for now
return new Vector(1, 0, 0);
}
private void cleanupOldRings() {
// Remove rings that are far behind all players
// Implementation depends on specific requirements
}
@NotNull
private ItemStack makeFirework(int amount) {
ItemStack firework = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) firework.getItemMeta();
if (meta != null) {
meta.setPower(1);
firework.setItemMeta(meta);
}
return firework;
}
private ParkourPlayer findParkourPlayer(@NotNull Player player) {
return getPlayers().stream()
.filter(pp -> pp.player.equals(player))
.findFirst()
.orElse(null);
}
}

View file

@ -97,6 +97,7 @@ public class RaceMode implements Mode {
}
}
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
private void updateProgressBar() {
int remaining = Math.max(0, targetScore - state.score);
int filled = Math.min(20, state.score * 20 / targetScore);

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;

View file

@ -115,10 +115,12 @@ public class SpeedrunMode implements Mode {
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) {
player.player.playSound(block.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
player.player.spawnParticle(Particle.FLAME,
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
for (ParkourPlayer pp : getPlayers()) {
pp.player.playSound(block.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
pp.player.spawnParticle(Particle.FLAME,
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
}
}
}, warningTicks);

View file

@ -0,0 +1,48 @@
package dev.loki.loparkour.mode.elytra;
import dev.loki.loparkour.config.Config;
/**
* Configuration settings for Elytra mode.
*/
public class ElytraConfig {
private final double ringDistanceMin;
private final double ringDistanceMax;
private final double ringSize;
private final int ringLead;
private final double maxHeadingChangeHorizontal;
private final double maxHeadingChangeVertical;
private final double maxHeightAboveSpawn;
private final double maxDeviation;
private final double boostPower;
private final long fireworkCooldownMs;
private final int startingFireworks;
public ElytraConfig() {
this.ringDistanceMin = Config.CONFIG.getDouble("modes.elytra.ring-distance-min");
this.ringDistanceMax = Config.CONFIG.getDouble("modes.elytra.ring-distance-max");
this.ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxHeadingChangeHorizontal = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
this.maxHeadingChangeVertical = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
this.maxHeightAboveSpawn = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
this.boostPower = Config.CONFIG.getDouble("modes.elytra.boost-power");
this.fireworkCooldownMs = (long) Config.CONFIG.getDouble("modes.elytra.firework-cooldown-ms");
this.startingFireworks = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
}
// Getters
public double getRingDistanceMin() { return ringDistanceMin; }
public double getRingDistanceMax() { return ringDistanceMax; }
public double getRingSize() { return ringSize; }
public int getRingLead() { return ringLead; }
public double getMaxHeadingChangeHorizontal() { return maxHeadingChangeHorizontal; }
public double getMaxHeadingChangeVertical() { return maxHeadingChangeVertical; }
public double getMaxHeightAboveSpawn() { return maxHeightAboveSpawn; }
public double getMaxDeviation() { return maxDeviation; }
public double getBoostPower() { return boostPower; }
public long getFireworkCooldownMs() { return fireworkCooldownMs; }
public int getStartingFireworks() { return startingFireworks; }
}

View file

@ -0,0 +1,140 @@
package dev.loki.loparkour.mode.elytra;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Handles physics calculations and validation for Elytra mode.
*/
public class ElytraPhysics {
private final ElytraConfig config;
private final Map<UUID, Long> lastFireworkTime = new HashMap<>();
public ElytraPhysics(@NotNull ElytraConfig config) {
this.config = config;
}
/**
* Check if any rings have been crossed by the player.
*/
public int checkRingCrossings(@NotNull ParkourPlayer player, @NotNull List<ElytraRing> rings, int currentRingIndex) {
Player p = player.player;
Location playerLoc = p.getLocation();
Vector velocity = p.getVelocity();
// Check rings starting from current index
for (int i = currentRingIndex; i < rings.size(); i++) {
ElytraRing ring = rings.get(i);
if (ring.hasCrossed(playerLoc, velocity)) {
ring.setPassed(true);
return i + 1; // Return new current ring index
}
// Only check a few rings ahead to avoid skipping
if (i > currentRingIndex + 2) break;
}
return currentRingIndex; // No change
}
/**
* Check if player should fall due to deviation from course.
*/
@NotNull
public FallCheckResult checkFall(@NotNull ParkourPlayer player, @NotNull List<ElytraRing> rings, int currentRingIndex) {
Player p = player.player;
Location playerLoc = p.getLocation();
if (rings.isEmpty() || currentRingIndex >= rings.size()) {
return new FallCheckResult(false, "No rings available");
}
// Check distance to current ring
ElytraRing currentRing = rings.get(currentRingIndex);
double distanceToRing = playerLoc.distance(currentRing.getCenter());
if (distanceToRing > config.getMaxDeviation()) {
return new FallCheckResult(true, "Too far from course (distance: " + String.format("%.1f", distanceToRing) + ")");
}
// Check if player is flying (has elytra deployed)
if (!p.isGliding()) {
return new FallCheckResult(true, "Not gliding");
}
return new FallCheckResult(false, null);
}
/**
* Apply firework boost to player if cooldown allows.
*/
public boolean applyFireworkBoost(@NotNull ParkourPlayer player) {
Player p = player.player;
UUID playerId = p.getUniqueId();
long currentTime = System.currentTimeMillis();
// Check cooldown
Long lastBoost = lastFireworkTime.get(playerId);
if (lastBoost != null && (currentTime - lastBoost) < config.getFireworkCooldownMs()) {
return false; // Still on cooldown
}
// Apply boost
Vector velocity = p.getVelocity();
Vector boostedVelocity = velocity.multiply(config.getBoostPower());
p.setVelocity(boostedVelocity);
// Update cooldown
lastFireworkTime.put(playerId, currentTime);
return true;
}
/**
* Check if player is centered when passing through a ring.
*/
public boolean isPlayerCentered(@NotNull Location playerLoc, @NotNull ElytraRing ring) {
Vector toPlayer = playerLoc.toVector().subtract(ring.getCenter().toVector());
Vector normal = ring.getNormal();
// Project player position onto ring plane
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(toPlayer.dot(normal)));
// Check if within center threshold (25% of ring radius)
double centerThreshold = ring.getRadius() * 0.25;
return projectedPos.length() <= centerThreshold;
}
/**
* Clean up physics data for a player.
*/
public void cleanup(@NotNull UUID playerId) {
lastFireworkTime.remove(playerId);
}
/**
* Result of fall check.
*/
public static class FallCheckResult {
private final boolean shouldFall;
private final String reason;
public FallCheckResult(boolean shouldFall, String reason) {
this.shouldFall = shouldFall;
this.reason = reason;
}
public boolean shouldFall() { return shouldFall; }
public String getReason() { return reason; }
}
}

View file

@ -0,0 +1,101 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.EntityType;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Handles visual rendering of Elytra rings and effects.
*/
public class ElytraRenderer {
private static final int RING_PARTICLES = 32;
private static final Color ACTIVE_RING_COLOR = Color.LIME;
private static final Color PASSED_RING_COLOR = Color.GRAY;
private static final Color UPCOMING_RING_COLOR = Color.YELLOW;
/**
* Render all rings with appropriate colors.
*/
public void renderRings(@NotNull List<ElytraRing> rings, int currentRingIndex) {
for (int i = 0; i < rings.size(); i++) {
ElytraRing ring = rings.get(i);
Color color = getRingColor(i, currentRingIndex, ring.isPassed());
renderRing(ring, color);
}
}
/**
* Render a single ring with the specified color.
*/
public void renderRing(@NotNull ElytraRing ring, @NotNull Color color) {
Location center = ring.getCenter();
Vector normal = ring.getNormal();
double radius = ring.getRadius();
// Create two perpendicular vectors to the normal
Vector u = findPerpendicular(normal);
Vector v = normal.getCrossProduct(u).normalize();
// Draw circle using particles
World world = center.getWorld();
if (world == null) return;
for (int i = 0; i < RING_PARTICLES; i++) {
double angle = 2 * Math.PI * i / RING_PARTICLES;
double x = radius * Math.cos(angle);
double y = radius * Math.sin(angle);
Vector offset = u.clone().multiply(x).add(v.clone().multiply(y));
Location particlePos = center.clone().add(offset);
// Spawn colored particle
Particle.DustOptions dustOptions = new Particle.DustOptions(color, 1.0f);
world.spawnParticle(Particle.REDSTONE, particlePos, 1, dustOptions);
}
// Create or update armor stand for ring center (optional visual aid)
if (ring.getEntity() == null) {
ArmorStand stand = (ArmorStand) world.spawnEntity(center, EntityType.ARMOR_STAND);
stand.setVisible(false);
stand.setGravity(false);
stand.setMarker(true);
stand.setCustomNameVisible(true);
ring.setEntity(stand);
}
// Update armor stand name with ring info
ArmorStand stand = ring.getEntity();
if (stand != null && !stand.isDead()) {
stand.setCustomName("Ring #" + ring.getId());
}
}
@NotNull
private Color getRingColor(int ringIndex, int currentRingIndex, boolean passed) {
if (passed) {
return PASSED_RING_COLOR;
} else if (ringIndex == currentRingIndex) {
return ACTIVE_RING_COLOR;
} else {
return UPCOMING_RING_COLOR;
}
}
@NotNull
private Vector findPerpendicular(@NotNull Vector v) {
// Find a vector perpendicular to v
if (Math.abs(v.getX()) < 0.9) {
return new Vector(1, 0, 0).getCrossProduct(v).normalize();
} else {
return new Vector(0, 1, 0).getCrossProduct(v).normalize();
}
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Location;
import org.bukkit.entity.ArmorStand;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
/**
* Represents a ring that players must fly through in Elytra mode.
*/
public class ElytraRing {
private final int id;
private final Location center;
private final Vector normal;
private final double radius;
private ArmorStand entity;
private boolean passed = false;
public ElytraRing(int id, @NotNull Location center, @NotNull Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Check if player has crossed through this ring.
*/
public boolean hasCrossed(@NotNull Location playerLoc, @NotNull Vector velocity) {
if (passed) return false;
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double distanceToPlane = toPlayer.dot(normal);
// Check if player is close to the ring plane
if (Math.abs(distanceToPlane) > 2.0) return false;
// Check if moving towards the ring
if (velocity.dot(normal) <= 0) return false;
// Check if within ring radius
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(distanceToPlane));
return projectedPos.length() <= radius;
}
/**
* Check if player is currently inside the ring.
*/
public boolean isInside(@NotNull Location playerLoc) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double distanceToPlane = Math.abs(toPlayer.dot(normal));
if (distanceToPlane > 1.0) return false;
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projectedPos.length() <= radius;
}
/**
* Remove the ring's visual representation.
*/
public void remove() {
if (entity != null && !entity.isDead()) {
entity.remove();
}
}
// Getters
public int getId() { return id; }
public Location getCenter() { return center.clone(); }
public Vector getNormal() { return normal.clone(); }
public double getRadius() { return radius; }
public ArmorStand getEntity() { return entity; }
public boolean isPassed() { return passed; }
// Setters
public void setEntity(ArmorStand entity) { this.entity = entity; }
public void setPassed(boolean passed) { this.passed = passed; }
}

View file

@ -0,0 +1,128 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Location;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* Generates rings for Elytra mode parkour.
*/
public class ElytraRingGenerator {
private final ElytraConfig config;
private int nextRingId = 0;
public ElytraRingGenerator(@NotNull ElytraConfig config) {
this.config = config;
}
/**
* Generate a series of rings starting from origin in the given direction.
*/
@NotNull
public List<ElytraRing> generateRings(@NotNull Location origin, @NotNull Vector direction, int count) {
List<ElytraRing> rings = new ArrayList<>();
Location currentPos = origin.clone();
Vector currentDir = direction.clone().normalize();
for (int i = 0; i < count; i++) {
// Calculate distance to next ring
double distance = calculateRingDistance();
// Move to next ring position
currentPos.add(currentDir.clone().multiply(distance));
// Apply course variation
currentDir = applyDirectionVariation(currentDir);
// Ensure we don't go too high or too low
currentPos = constrainHeight(currentPos, origin);
// Create ring with perpendicular normal
Vector normal = findPerpendicularVector(currentDir);
ElytraRing ring = new ElytraRing(nextRingId++, currentPos.clone(), normal, config.getRingSize());
rings.add(ring);
}
return rings;
}
private double calculateRingDistance() {
return ThreadLocalRandom.current().nextDouble(
config.getRingDistanceMin(),
config.getRingDistanceMax()
);
}
@NotNull
private Vector applyDirectionVariation(@NotNull Vector currentDir) {
double maxHorizontalChange = Math.toRadians(config.getMaxHeadingChangeHorizontal());
double maxVerticalChange = Math.toRadians(config.getMaxHeadingChangeVertical());
// Random horizontal rotation
double yawChange = ThreadLocalRandom.current().nextDouble(-maxHorizontalChange, maxHorizontalChange);
// Random vertical rotation
double pitchChange = ThreadLocalRandom.current().nextDouble(-maxVerticalChange, maxVerticalChange);
return rotateVector(currentDir, yawChange, pitchChange);
}
@NotNull
private Location constrainHeight(@NotNull Location pos, @NotNull Location origin) {
double maxHeight = origin.getY() + config.getMaxHeightAboveSpawn();
double minHeight = origin.getY() - 20; // Don't go too low
if (pos.getY() > maxHeight) {
pos.setY(maxHeight);
} else if (pos.getY() < minHeight) {
pos.setY(minHeight);
}
return pos;
}
@NotNull
private Vector findPerpendicularVector(@NotNull Vector v) {
// Find a vector perpendicular to v
Vector perp;
if (Math.abs(v.getX()) < 0.9) {
perp = new Vector(1, 0, 0);
} else {
perp = new Vector(0, 1, 0);
}
// Make it truly perpendicular using cross product
return v.getCrossProduct(perp).normalize();
}
@NotNull
private Vector rotateVector(@NotNull Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cosYaw = Math.cos(yawRad);
double sinYaw = Math.sin(yawRad);
double newX = v.getX() * cosYaw - v.getZ() * sinYaw;
double newZ = v.getX() * sinYaw + v.getZ() * cosYaw;
Vector rotated = new Vector(newX, v.getY(), newZ);
// Rotate around perpendicular axis (pitch)
Vector axis = findPerpendicularVector(rotated);
double cosPitch = Math.cos(pitchRad);
double sinPitch = Math.sin(pitchRad);
// Rodrigues' rotation formula
Vector result = rotated.clone().multiply(cosPitch);
result.add(axis.getCrossProduct(rotated).multiply(sinPitch));
result.add(axis.clone().multiply(axis.dot(rotated) * (1 - cosPitch)));
return result.normalize();
}
}

View file

@ -1,9 +1,5 @@
package dev.loki.loparkour.player;
import java.util.ArrayList;
import dev.lolib.scheduler.Scheduler;
import com.google.gson.annotations.Expose;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
@ -17,10 +13,10 @@ import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.Divider;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.jetbrains.annotations.NotNull;
@ -38,6 +34,9 @@ public class ParkourPlayer extends ParkourUser {
public static final Map<String, PlayerSettingsManager.OptionContainer> PLAYER_COLUMNS = PlayerSettingsManager.getColumnMappings();
// Track scored blocks to prevent duplicate scoring
private final Set<Block> scoredBlocks = new HashSet<>();
public @Expose Double schematicDifficulty;
public @Expose Integer blockLead;
public @Expose Boolean particles;
@ -68,10 +67,6 @@ public class ParkourPlayer extends ParkourUser {
}
}
private static boolean parseBoolean(String string) {
return string == null || string.equals("1") || string.equals("true");
}
/**
* @param player The player.
* @return True when this player is a {@link ParkourPlayer}, false if not.
@ -111,10 +106,27 @@ public class ParkourPlayer extends ParkourUser {
}
session.removePlayers(this);
// Clear scored blocks to prevent memory leak
scoredBlocks.clear();
save(LoParkour.getPlugin().isEnabled());
}
/**
* Check if player has already scored on this block.
*/
public boolean hasScored(Block block) {
return scoredBlocks.contains(block);
}
/**
* Mark block as scored to prevent duplicate scoring.
*/
public void markScored(Block block) {
scoredBlocks.add(block);
}
/**
* Sets the user's settings. If an item is not included, the setting gets reset.
*
@ -140,7 +152,8 @@ public class ParkourPlayer extends ParkourUser {
.set("selectedTime", selectedTime.toString())
.set("style", style);
generator.overrideProfile();
// Use profileManager instead of calling overrideProfile directly on generator
generator.profileManager.overrideProfile();
}
/**

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.player;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourSpectateEvent;
import dev.loki.loparkour.config.Locales;
@ -9,11 +7,11 @@ import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.ColorUtil;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import dev.lolib.scheduler.ScheduledTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -26,7 +24,7 @@ import java.util.Comparator;
*/
public class ParkourSpectator extends ParkourUser {
private final dev.lolib.scheduler.ScheduledTask closestChecker;
private final ScheduledTask closestChecker;
/**
* The closest player.
*/
@ -54,7 +52,8 @@ public class ParkourSpectator extends ParkourUser {
}
}, 1);
closestChecker = Scheduler.get(LoParkour.getPlugin()).runTimerAsync(() -> {
// Use runTimer (sync) instead of runTimerAsync to safely access Bukkit API
closestChecker = Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
if (session.getPlayers().isEmpty()) {
return;
}
@ -68,6 +67,7 @@ public class ParkourSpectator extends ParkourUser {
/**
* Updates the spectator's action bar, scoreboard and checks distance.
*/
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
public void update() {
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(ColorUtil.color(Locales.getString(player, "play.spectator.action_bar"))));
player.setGameMode(GameMode.SPECTATOR);

View file

@ -68,6 +68,7 @@ public abstract class ParkourUser {
public final Instant joined;
private ScoreboardManager scoreboardManager;
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
this.player = player;
this.session = session;

View file

@ -25,18 +25,21 @@ public class PlayerSettingsManager {
private static void initializeColumnMappings() {
COLUMN_MAPPINGS.put("uuid", new OptionContainer(null, null));
COLUMN_MAPPINGS.put("style", new OptionContainer(ParkourOption.STYLES, (p, v) -> p.style = v));
COLUMN_MAPPINGS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (p, v) -> p.blockLead = Integer.parseInt(v)));
COLUMN_MAPPINGS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (p, v) ->
p.blockLead = parseIntSafe(v, defaultBlockLead())));
COLUMN_MAPPINGS.put("useParticles", new OptionContainer(ParkourOption.PARTICLES, (p, v) -> p.particles = parseBoolean(v)));
COLUMN_MAPPINGS.put("useSpecial", new OptionContainer(ParkourOption.SPECIAL_BLOCKS, (p, v) -> p.useSpecialBlocks = parseBoolean(v)));
COLUMN_MAPPINGS.put("showFallMsg", new OptionContainer(ParkourOption.FALL_MESSAGE, (p, v) -> p.showFallMessage = parseBoolean(v)));
COLUMN_MAPPINGS.put("showScoreboard", new OptionContainer(ParkourOption.SCOREBOARD, (p, v) -> p.showScoreboard = parseBoolean(v)));
COLUMN_MAPPINGS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (p, v) -> p.selectedTime = Integer.parseInt(v)));
COLUMN_MAPPINGS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (p, v) ->
p.selectedTime = parseIntSafe(v, defaultSelectedTime())));
COLUMN_MAPPINGS.put("collectedRewards", new OptionContainer(null, PlayerSettingsManager::applyCollectedRewards));
COLUMN_MAPPINGS.put("locale", new OptionContainer(ParkourOption.LANG, (p, v) -> {
p._locale = v;
p.locale = v;
}));
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) -> p.schematicDifficulty = Double.parseDouble(v)));
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) ->
p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty())));
COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v)));
}
@ -64,7 +67,49 @@ public class PlayerSettingsManager {
}
private static boolean parseBoolean(String string) {
return string == null || string.equals("1") || string.equals("true");
return string == null || string.equals("1") || string.equalsIgnoreCase("true");
}
private static int defaultBlockLead() {
return parseIntSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.LEADS, "1"), 1);
}
private static int defaultSelectedTime() {
return parseIntSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.TIME, "6000"), 6000);
}
private static double defaultSchematicDifficulty() {
return parseDoubleSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.SCHEMATICS, "0.6"), 0.6);
}
private static int parseIntSafe(String v, int fallback) {
if (v == null) {
return fallback;
}
String s = v.trim();
if (s.isEmpty() || "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
return fallback;
}
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return fallback;
}
}
private static double parseDoubleSafe(String v, double fallback) {
if (v == null) {
return fallback;
}
String s = v.trim();
if (s.isEmpty() || "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
return fallback;
}
try {
return Double.parseDouble(s);
} catch (NumberFormatException e) {
return fallback;
}
}
private static void applyCollectedRewards(ParkourPlayer player, String value) {

View file

@ -68,6 +68,7 @@ class ScoreboardManager {
return new Score("?", "?", "?", 0);
}
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
private void updateTitle(Score top, Score high, ParkourGenerator generator) {
Objective obj = board.getObjective("lp_sidebar");
if (obj == null) {

View file

@ -1,16 +1,14 @@
package dev.loki.loparkour.player.data;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import org.jetbrains.annotations.Nullable;
import java.io.*;
@ -18,6 +16,10 @@ import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
/**
* Saved hotbar/inventory slots for restore on leave. New files use YAML
* (serializable ItemStacks); legacy Java-serialized files are still read once.
*/
public class InventoryData {
private final File file;
@ -51,11 +53,46 @@ public class InventoryData {
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> loadFile(onFinish));
}
@SuppressWarnings("unchecked")
private void loadFile(Consumer<@Nullable InventoryData> onFinish) {
try {
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
if (!yaml.getKeys(false).isEmpty()) {
loadFromYaml(yaml);
onFinish.accept(this);
return;
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().warning(
"YAML inventory parse failed for %s, trying legacy format: %s".formatted(file.getName(), ex.getMessage()));
}
if (file.length() == 0) {
onFinish.accept(null);
return;
}
loadLegacy(onFinish);
}
private void loadFromYaml(YamlConfiguration yaml) {
items = new HashMap<>();
for (String key : yaml.getKeys(false)) {
try {
int slot = Integer.parseInt(key);
ItemStack stack = yaml.getItemStack(key);
if (stack != null) {
items.put(slot, stack);
}
} catch (NumberFormatException ignored) {
// skip non-slot keys
}
}
}
@SuppressWarnings("unchecked")
private void loadLegacy(Consumer<@Nullable InventoryData> onFinish) {
try (BukkitObjectInputStream stream = new BukkitObjectInputStream(new BufferedInputStream(new FileInputStream(file)))) {
items = (Map<Integer, ItemStack>) stream.readObject();
onFinish.accept(this);
} catch (IOException | ClassNotFoundException ex) {
LoParkour.getPlugin().getLogger().severe("Error while reading inventory of %s from file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
@ -98,9 +135,13 @@ public class InventoryData {
LoParkour.getPlugin().getLogger().severe("Error while creating file to save inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
}
try (ObjectOutputStream stream = new BukkitObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) {
stream.writeObject(items);
stream.flush();
try {
YamlConfiguration yaml = new YamlConfiguration();
yaml.options().header(" LoParkour inventory (YAML). Legacy binary saves are migrated on next save.");
for (Map.Entry<Integer, ItemStack> e : items.entrySet()) {
yaml.set(String.valueOf(e.getKey()), e.getValue());
}
yaml.save(file);
} catch (IOException ex) {
LoParkour.getPlugin().getLogger().severe("Error while saving inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
}

View file

@ -47,6 +47,15 @@ public class Rewards {
INTERVAL_REWARDS = parseScores("interval-rewards");
ONE_TIME_REWARDS = parseScores("one-time-rewards");
}
/**
* Clear all reward maps to prevent memory leaks on reload
*/
public static void clear() {
SCORE_REWARDS.clear();
INTERVAL_REWARDS.clear();
ONE_TIME_REWARDS.clear();
}
private static Map<Integer, List<Reward>> parseScores(String path) {
Map<Integer, List<Reward>> rewardMap = new HashMap<>();

View file

@ -1,30 +1,17 @@
package dev.loki.loparkour.schematic;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.lolib.scheduler.Scheduler;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Stores schematics, so they don't have to be read every time.
* Schematic folder discovery (files are loaded on demand elsewhere).
*/
public class Schematics {
private static final String[] SPAWN_SCHEMATICS = new String[]{
"spawn-island", "spawn-island-duels"
};
private static final File FOLDER = LoParkour.getInFolder("schematics");
/**
* Reads all files.
*/
public static void init() {
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> {
if (!FOLDER.exists()) {
@ -37,7 +24,7 @@ public class Schematics {
LoParkour.log("No schematics found in " + FOLDER.getAbsolutePath());
return;
}
LoParkour.log("Found " + files.length + " schematic files");
});
}

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.schematic.lpschem;
import org.bukkit.Bukkit;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.bukkit.Location;

View file

@ -1,244 +1,154 @@
package dev.loki.loparkour.session;
import java.util.ArrayList;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* <p>A session is bound to a {@link Divider} section.
* It manages all players, all spectators, visibility, the generator, etc.</p>
* <p>Iteration 2.</p>
*
* @author loki
* @since 5.0.0
* Refactored Session using composition pattern.
* Coordinates player management and state management.
*/
public class Session {
/**
* The spawn location of this session.
*/
private Location spawnLocation;
/**
* The generator.
*/
public ParkourGenerator generator;
private final SessionPlayerManager playerManager;
private final SessionStateManager stateManager;
private Session() {
this.playerManager = new SessionPlayerManager(this);
this.stateManager = new SessionStateManager(this);
}
/**
* The visibility of this session. Default public.
* Create a new session with specified parameters.
*/
private Visibility visibility = Visibility.PUBLIC;
/**
* Manages users (players and spectators) in this session.
*/
private final SessionUserManager userManager = new SessionUserManager(this);
/**
* Function that takes the current session and returns whether new players should be accepted.
*/
private Function<Session, Boolean> isAcceptingPlayers = session -> false;
/**
* Function that takes the current session and returns whether new spectators should be accepted.
*/
private Function<Session, Boolean> isAcceptingSpectators = session -> session.visibility == Visibility.PUBLIC;
/**
* Creates a new session.
*
* @param generatorFunction The generator function.
* @param isAcceptingPlayers The function that takes the current session and returns whether new players should be accepted.
* @param isAcceptingSpectators The function that takes the current session and returns whether new spectators should be accepted.
* @param players The players.
* @return The session.
*/
public static Session create(Function<Session, ParkourGenerator> generatorFunction,
Function<Session, Boolean> isAcceptingPlayers,
Function<Session, Boolean> isAcceptingSpectators,
Player... players) {
@NotNull
public static Session create(
@NotNull Function<Session, ParkourGenerator> generatorFunction,
Function<Session, Boolean> isAcceptingPlayers,
Function<Session, Boolean> isAcceptingSpectators,
Player... players) {
Session session = new Session();
if (isAcceptingPlayers != null) session.isAcceptingPlayers = isAcceptingPlayers;
if (isAcceptingSpectators != null) session.isAcceptingSpectators = isAcceptingSpectators;
List<ParkourPlayer> pps = new ArrayList<>();
// Set acceptance functions
if (isAcceptingPlayers != null) {
session.playerManager.setAcceptingPlayers(isAcceptingPlayers);
}
if (isAcceptingSpectators != null) {
session.playerManager.setAcceptingSpectators(isAcceptingSpectators);
}
// Add initial players
List<ParkourPlayer> parkourPlayers = new ArrayList<>();
if (players != null) {
for (Player player : players) {
ParkourPlayer pp = ParkourUser.register(player, session);
session.addPlayers(pp);
pps.add(pp);
parkourPlayers.add(pp);
}
}
session.spawnLocation = Divider.add(session);
session.generator = generatorFunction.apply(session);
if (players != null) {
pps.forEach(p -> p.updateGeneratorSettings(session.generator));
// Initialize session state
session.stateManager.initialize(generatorFunction);
// Update generator settings for players
if (session.generator != null) {
parkourPlayers.forEach(p -> p.updateGeneratorSettings(session.generator));
}
session.generator.island.build(session.spawnLocation);
return session;
}
/**
* Sets the visibility of this session.
* @param visibility The visibility.
*/
public void setVisibility(Visibility visibility) {
this.visibility = visibility;
// Visibility management
public void setVisibility(@NotNull Visibility visibility) {
stateManager.setVisibility(visibility);
}
/**
* @return The visibility of this session.
*/
@NotNull
public Visibility getVisibility() {
return visibility;
return stateManager.getVisibility();
}
/**
* Adds provided players to this session's player list.
*
* @param toAdd The players to add.
*/
public void addPlayers(ParkourPlayer... toAdd) {
userManager.addPlayers(toAdd);
// Player management
public void addPlayers(@NotNull ParkourPlayer... toAdd) {
playerManager.addPlayers(toAdd);
}
/**
* Removes provided players from this session's player list.
*
* @param toRemove The players to remove.
*/
public void removePlayers(ParkourPlayer... toRemove) {
userManager.removePlayers(toRemove);
public void removePlayers(@NotNull ParkourPlayer... toRemove) {
playerManager.removePlayers(toRemove);
}
/**
* @return The players.
*/
@NotNull
public List<ParkourPlayer> getPlayers() {
return userManager.getPlayers();
return playerManager.getPlayers();
}
/**
* Adds provided spectators to this session's spectator list.
*
* @param spectators The spectators to add.
*/
public void addSpectators(ParkourSpectator... spectators) {
userManager.addSpectators(spectators);
// Spectator management
public void addSpectators(@NotNull ParkourSpectator... spectators) {
playerManager.addSpectators(spectators);
}
/**
* Removes provided spectators from this session's spectator list.
*
* @param spectators The spectators to remove.
*/
public void removeSpectators(ParkourSpectator... spectators) {
userManager.removeSpectators(spectators);
public void removeSpectators(@NotNull ParkourSpectator... spectators) {
playerManager.removeSpectators(spectators);
}
/**
* @return The spectators.
*/
@NotNull
public List<ParkourSpectator> getSpectators() {
return userManager.getSpectators();
return playerManager.getSpectators();
}
/**
* @return The users.
*/
@NotNull
public List<ParkourUser> getUsers() {
return userManager.getUsers();
return playerManager.getUsers();
}
/**
* @return the spawn location for this {@link Session}.
*/
@SuppressWarnings("unused")
// State management
public Location getSpawnLocation() {
return spawnLocation.clone();
return stateManager.getSpawnLocation();
}
/**
* Toggles mute for the specified user.
*
* @param user The user to (un)mute.
*/
public void toggleMute(@NotNull ParkourUser user) {
userManager.toggleMute(user);
stateManager.toggleMute(user);
}
/**
* @param user The user.
* @return True when the user is muted, false if not.
*/
public boolean isMuted(@NotNull ParkourUser user) {
return userManager.isMuted(user);
return stateManager.isMuted(user);
}
/**
* Called when all players have left the session.
*/
void onAllPlayersLeft() {
generator.reset(false);
Divider.remove(this);
}
/**
* @return True when players may join this session, false if not.
*/
// Acceptance checks
public boolean isAcceptingPlayers() {
return isAcceptingPlayers.apply(this);
return playerManager.isAcceptingPlayers();
}
/**
* @return True when spectators may join this session, false if not.
*/
public boolean isAcceptingSpectators() {
return isAcceptingSpectators.apply(this);
return playerManager.isAcceptingSpectators();
}
// Lifecycle
public void onAllPlayersLeft() {
stateManager.cleanup();
}
void setGenerator(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
// Enums
public enum Visibility {
/**
* No-one can join.
*/
PRIVATE,
/**
* Only people with the session id can join.
*/
ID_ONLY,
/**
* Anyone can join.
*/
PUBLIC,
PRIVATE, ID_ONLY, PUBLIC
}
/**
* An enum for all available chat types that a player can select while playing
*/
public enum ChatType {
LOBBY_ONLY, PLAYERS_ONLY, PUBLIC
}
}
}

View file

@ -0,0 +1,147 @@
package dev.loki.loparkour.session;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.player.ParkourUser;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
/**
* Manages players and spectators in a session.
*/
public class SessionPlayerManager {
private final Session session;
private final List<ParkourPlayer> players = new ArrayList<>();
private final List<ParkourSpectator> spectators = new ArrayList<>();
private Function<Session, Boolean> isAcceptingPlayers = s -> false;
private Function<Session, Boolean> isAcceptingSpectators = s -> s.getVisibility() == Session.Visibility.PUBLIC;
public SessionPlayerManager(@NotNull Session session) {
this.session = session;
}
/**
* Set player acceptance function.
*/
public void setAcceptingPlayers(@NotNull Function<Session, Boolean> function) {
this.isAcceptingPlayers = function;
}
/**
* Set spectator acceptance function.
*/
public void setAcceptingSpectators(@NotNull Function<Session, Boolean> function) {
this.isAcceptingSpectators = function;
}
/**
* Check if session is accepting players.
*/
public boolean isAcceptingPlayers() {
return isAcceptingPlayers.apply(session);
}
/**
* Check if session is accepting spectators.
*/
public boolean isAcceptingSpectators() {
return isAcceptingSpectators.apply(session);
}
/**
* Add players to session.
*/
public void addPlayers(@NotNull ParkourPlayer... toAdd) {
players.addAll(Arrays.asList(toAdd));
updateVisibility();
}
/**
* Remove players from session.
*/
public void removePlayers(@NotNull ParkourPlayer... toRemove) {
players.removeAll(Arrays.asList(toRemove));
if (players.isEmpty()) {
session.onAllPlayersLeft();
} else {
updateVisibility();
}
}
/**
* Add spectators to session.
*/
public void addSpectators(@NotNull ParkourSpectator... toAdd) {
spectators.addAll(Arrays.asList(toAdd));
updateVisibility();
}
/**
* Remove spectators from session.
*/
public void removeSpectators(@NotNull ParkourSpectator... toRemove) {
spectators.removeAll(Arrays.asList(toRemove));
updateVisibility();
}
/**
* Get all players.
*/
@NotNull
public List<ParkourPlayer> getPlayers() {
return new ArrayList<>(players);
}
/**
* Get all spectators.
*/
@NotNull
public List<ParkourSpectator> getSpectators() {
return new ArrayList<>(spectators);
}
/**
* Get all users (players + spectators).
*/
@NotNull
public List<ParkourUser> getUsers() {
List<ParkourUser> users = new ArrayList<>();
users.addAll(players);
users.addAll(spectators);
return users;
}
/**
* Get total user count.
*/
public int getTotalUsers() {
return players.size() + spectators.size();
}
/**
* Check if session has any users.
*/
public boolean hasUsers() {
return !players.isEmpty() || !spectators.isEmpty();
}
private void updateVisibility() {
// Update player visibility for all users
List<ParkourUser> allUsers = getUsers();
for (ParkourUser user : allUsers) {
for (ParkourUser other : allUsers) {
if (!user.equals(other)) {
user.player.showPlayer(dev.loki.loparkour.LoParkour.getPlugin(), other.player);
}
}
}
}
}

View file

@ -0,0 +1,105 @@
package dev.loki.loparkour.session;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Manages session state and lifecycle.
*/
public class SessionStateManager {
private final Session session;
private Location spawnLocation;
private Session.Visibility visibility = Session.Visibility.PUBLIC;
private final Map<ParkourUser, Boolean> mutedUsers = new HashMap<>();
public SessionStateManager(@NotNull Session session) {
this.session = session;
}
/**
* Initialize session with generator and spawn location.
*/
public void initialize(@NotNull Function<Session, ParkourGenerator> generatorFunction) {
// Allocate spawn location
this.spawnLocation = Divider.add(session);
// Create generator
ParkourGenerator generator = generatorFunction.apply(session);
session.setGenerator(generator);
// Build island at spawn location
generator.island.build(spawnLocation);
}
/**
* Clean up session resources.
*/
public void cleanup() {
if (session.generator != null) {
session.generator.reset(false);
}
Divider.remove(session);
mutedUsers.clear();
}
/**
* Set session visibility.
*/
public void setVisibility(@NotNull Session.Visibility visibility) {
this.visibility = visibility;
}
/**
* Get session visibility.
*/
@NotNull
public Session.Visibility getVisibility() {
return visibility;
}
/**
* Get spawn location.
*/
@Nullable
public Location getSpawnLocation() {
return spawnLocation != null ? spawnLocation.clone() : null;
}
/**
* Toggle mute status for user.
*/
public void toggleMute(@NotNull ParkourUser user) {
mutedUsers.put(user, !isMuted(user));
}
/**
* Check if user is muted.
*/
public boolean isMuted(@NotNull ParkourUser user) {
return mutedUsers.getOrDefault(user, false);
}
/**
* Get muted users count.
*/
public int getMutedUsersCount() {
return (int) mutedUsers.values().stream().mapToInt(muted -> muted ? 1 : 0).sum();
}
/**
* Clear all muted users.
*/
public void clearMutedUsers() {
mutedUsers.clear();
}
}

View file

@ -1,32 +1,31 @@
package dev.loki.loparkour.storage;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Option;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Manages SQL database connection lifecycle.
*
* @since 5.0.0
* MySQL access via HikariCP pool.
*/
class SQLConnectionManager {
private Connection connection;
private HikariDataSource dataSource;
private volatile boolean connected = false;
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
public boolean isConnected() {
return connected;
return connected && dataSource != null && !dataSource.isClosed();
}
public void runWhenConnected(Runnable callback) {
if (connected) {
if (isConnected()) {
callback.run();
} else {
onConnectCallbacks.add(callback);
@ -35,14 +34,19 @@ class SQLConnectionManager {
public void connect() {
try {
LoParkour.log("Connecting to MySQL...");
loadDriver();
LoParkour.log("Connecting to MySQL (pool)...");
connection = DriverManager.getConnection(
buildConnectionUrl(),
Option.SQL_USERNAME,
Option.SQL_PASSWORD);
HikariConfig config = new HikariConfig();
config.setJdbcUrl(buildConnectionUrl());
config.setUsername(Option.SQL_USERNAME);
config.setPassword(Option.SQL_PASSWORD);
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(5000);
config.setMaxLifetime(1_800_000);
config.setPoolName("LoParkour");
dataSource = new HikariDataSource(config);
connected = true;
LoParkour.log("Connected to MySQL");
@ -53,23 +57,17 @@ class SQLConnectionManager {
}
public void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
LoParkour.log("Closed connection to MySQL");
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to close connection to SQL database - " + ex.getMessage());
connected = false;
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
LoParkour.log("Closed MySQL pool");
}
dataSource = null;
}
public PreparedStatement prepareStatement(String sql) {
validateConnection();
if (connection == null) return null;
try {
return connection.prepareStatement(sql);
return getConnection().prepareStatement(sql);
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
@ -77,30 +75,27 @@ class SQLConnectionManager {
}
}
public Connection getConnection() throws SQLException {
if (!isConnected()) {
throw new SQLException("Database not connected");
}
return dataSource.getConnection();
}
/**
* Ensures the pool was created; does not recreate the pool on failure (restart required).
*/
public void validateConnection() {
try {
if (connection == null || !connection.isValid(2)) {
LoParkour.getPlugin().getLogger().warning("MySQL connection lost, attempting reconnect...");
connect();
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error reconnecting to MySQL - " + ex.getMessage());
if (!isConnected()) {
LoParkour.getPlugin().getLogger().warning("MySQL pool not available");
Option.SQL = false;
}
}
private void loadDriver() throws ClassNotFoundException {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
Class.forName("com.mysql.jdbc.Driver");
}
}
private String buildConnectionUrl() {
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true" +
"&useSSL=false&useUnicode=true&characterEncoding=utf-8" +
"&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true"
+ "&useSSL=false&useUnicode=true&characterEncoding=utf-8"
+ "&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
.formatted(Option.SQL_URL, Option.SQL_PORT, Option.SQL_DB);
}
@ -120,5 +115,10 @@ class SQLConnectionManager {
"Could not connect to MySQL - check your SQL settings - " + ex.getMessage());
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
Option.SQL = false;
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
dataSource = null;
connected = false;
}
}

View file

@ -0,0 +1,120 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.player.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Maps data between Java objects and SQL result sets.
*/
public class SQLDataMapper {
/**
* Map ResultSet to Score objects.
*/
@NotNull
public static Map<UUID, Score> mapScores(@NotNull ResultSet rs) throws SQLException {
Map<UUID, Score> scores = new HashMap<>();
while (rs.next()) {
UUID uuid = UUID.fromString(rs.getString("uuid"));
String name = rs.getString("name");
int score = rs.getInt("score");
String time = rs.getString("time");
String difficulty = rs.getString("difficulty");
Score scoreObj = new Score(name, time, difficulty, score);
scores.put(uuid, scoreObj);
}
return scores;
}
/**
* Set Score parameters in PreparedStatement.
*/
public static void setScoreParameters(@NotNull PreparedStatement stmt, @NotNull Score score, @NotNull UUID playerUuid) throws SQLException {
stmt.setString(1, playerUuid.toString());
stmt.setString(2, score.name());
stmt.setInt(3, score.score());
stmt.setString(4, score.time());
stmt.setString(5, score.difficulty());
stmt.setLong(6, System.currentTimeMillis());
}
/**
* Map ResultSet to ParkourPlayer data.
*/
@Nullable
public static PlayerData mapPlayerData(@NotNull ResultSet rs) throws SQLException {
if (!rs.next()) {
return null;
}
UUID uuid = UUID.fromString(rs.getString("uuid"));
String name = rs.getString("name");
String locale = rs.getString("locale");
String styleName = rs.getString("style");
String settings = rs.getString("settings");
long lastSeen = rs.getLong("last_seen");
return new PlayerData(uuid, name, locale, styleName, settings, lastSeen);
}
/**
* Set ParkourPlayer parameters in PreparedStatement.
*/
public static void setPlayerParameters(@NotNull PreparedStatement stmt, @NotNull ParkourPlayer player) throws SQLException {
stmt.setString(1, player.player.getUniqueId().toString());
stmt.setString(2, player.player.getName());
stmt.setString(3, player.locale);
stmt.setString(4, player.style);
stmt.setString(5, serializeSettings(player));
stmt.setLong(6, System.currentTimeMillis());
}
/**
* Apply player data to ParkourPlayer object.
*/
public static void applyPlayerData(@NotNull ParkourPlayer player, @NotNull PlayerData data) {
player.locale = data.locale();
player.style = data.styleName();
// Apply settings if available
if (data.settings() != null && !data.settings().isEmpty()) {
deserializeSettings(player, data.settings());
}
}
@NotNull
private static String serializeSettings(@NotNull ParkourPlayer player) {
// Simplified settings serialization
// In real implementation, would serialize player settings to JSON
return "{}";
}
private static void deserializeSettings(@NotNull ParkourPlayer player, @NotNull String settings) {
// Simplified settings deserialization
// In real implementation, would deserialize JSON to player settings
}
/**
* Player data record for mapping.
*/
public record PlayerData(
UUID uuid,
String name,
String locale,
String styleName,
String settings,
long lastSeen
) {}
}

View file

@ -0,0 +1,104 @@
package dev.loki.loparkour.storage;
import org.jetbrains.annotations.NotNull;
/**
* Builds SQL queries for parkour storage operations.
*/
public class SQLQueryBuilder {
/**
* Build CREATE TABLE query for scores.
*/
@NotNull
public static String createScoresTable(@NotNull String tableName) {
return String.format("""
CREATE TABLE IF NOT EXISTS %s (
uuid VARCHAR(36) PRIMARY KEY,
name VARCHAR(16) NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
time VARCHAR(32) NOT NULL DEFAULT '00:00.000',
difficulty VARCHAR(16) NOT NULL DEFAULT '1.0',
timestamp BIGINT NOT NULL DEFAULT 0
)
""", tableName);
}
/**
* Build CREATE TABLE query for players.
*/
@NotNull
public static String createPlayersTable() {
return """
CREATE TABLE IF NOT EXISTS players (
uuid VARCHAR(36) PRIMARY KEY,
name VARCHAR(16) NOT NULL,
locale VARCHAR(8) NOT NULL DEFAULT 'en',
style VARCHAR(32) NOT NULL DEFAULT 'default',
settings TEXT,
last_seen BIGINT NOT NULL DEFAULT 0
)
""";
}
/**
* Build SELECT query for scores.
*/
@NotNull
public static String selectScores(@NotNull String tableName) {
return String.format(
"SELECT uuid, name, score, time, difficulty, timestamp FROM %s ORDER BY score DESC",
tableName
);
}
/**
* Build INSERT/UPDATE query for scores.
*/
@NotNull
public static String upsertScore(@NotNull String tableName) {
return String.format("""
INSERT INTO %s (uuid, name, score, time, difficulty, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
score = VALUES(score),
time = VALUES(time),
difficulty = VALUES(difficulty),
timestamp = VALUES(timestamp)
""", tableName);
}
/**
* Build SELECT query for player data.
*/
@NotNull
public static String selectPlayer() {
return "SELECT uuid, name, locale, style, settings, last_seen FROM players WHERE uuid = ?";
}
/**
* Build INSERT/UPDATE query for player data.
*/
@NotNull
public static String upsertPlayer() {
return """
INSERT INTO players (uuid, name, locale, style, settings, last_seen)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
locale = VALUES(locale),
style = VALUES(style),
settings = VALUES(settings),
last_seen = VALUES(last_seen)
""";
}
/**
* Get table name for specific mode.
*/
@NotNull
public static String getTableName(@NotNull String mode) {
return "scores_" + mode.toLowerCase().replace(" ", "_");
}
}

View file

@ -1,246 +1,182 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import java.sql.*;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
/**
* MySQL storage manager.
*
* <p><b>Security:</b> Every query that touches user-supplied data uses
* {@code ?} placeholders bound via {@link PreparedStatement#setObject}.
* No string interpolation of player names, UUIDs, scores, or any other
* external value ever reaches the SQL string itself.</p>
*
* @since 5.0.0
* Refactored StorageSQL using composition pattern.
* Coordinates connection management, query building, and data mapping.
*/
class StorageSQL {
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
private static boolean initialized = false;
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
// ── Connection lifecycle ──────────────────────────────────────────────────
public static boolean isConnected() {
return connectionManager.isConnected();
}
public static void runWhenConnected(Runnable callback) {
connectionManager.runWhenConnected(callback);
}
public static void close() {
connectionManager.close();
}
// ── Initialisation ────────────────────────────────────────────────────────
public class StorageSQL {
private static final Logger logger = Logger.getLogger(StorageSQL.class.getName());
private static SQLConnectionManager connectionManager;
/**
* Queues creation of the leaderboard table for {@code mode}.
* Table name is constructed from a config-defined prefix (admin-controlled,
* not user input) so formatting here is safe.
* Check if database is connected.
*/
public static void init(String mode) {
// Table names are derived from admin config, not player input — safe to format.
String createTableSql = """
CREATE TABLE IF NOT EXISTS `%s`
(
uuid CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(16),
time VARCHAR(16),
difficulty VARCHAR(3),
score INT
) CHARSET = utf8 ENGINE = InnoDB;
""".formatted(getTableName(mode));
if (!initialized) {
initialized = true;
pendingTableCreations.add(createTableSql);
Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect);
} else {
pendingTableCreations.add(createTableSql);
}
public static boolean isConnected() {
return connectionManager != null && connectionManager.isConnected();
}
// ── Leaderboard ───────────────────────────────────────────────────────────
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
// Table name from admin config — safe to format; no user data here.
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) return new HashMap<>();
try (ResultSet rs = stmt.executeQuery()) {
Map<UUID, Score> scores = new HashMap<>();
while (rs.next()) {
scores.put(
UUID.fromString(rs.getString("uuid")),
new Score(
rs.getString("name"),
rs.getString("time"),
rs.getString("difficulty"),
rs.getInt("score")));
}
return scores;
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage()));
return new HashMap<>();
}
}
/**
* Writes all scores for a mode.
*
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
* Player name, time, difficulty come from the leaderboard record — all bound
* as parameters, never interpolated.</p>
* Run callback when database is connected.
*/
public static void runWhenConnected(@NotNull Runnable callback) {
if (isConnected()) {
callback.run();
} else {
logger.warning("Database not connected, skipping operation");
}
}
/**
* Close database connection.
*/
public static void close() {
if (connectionManager != null) {
connectionManager.close();
connectionManager = null;
}
}
/**
* Initialize database connection and tables.
*/
public static void init(@NotNull String mode) {
try {
connectionManager = new SQLConnectionManager();
connectionManager.connect();
createTables(mode);
logger.info("Database initialized successfully for mode: " + mode);
} catch (SQLException e) {
logger.severe("Failed to initialize database: " + e.getMessage());
}
}
/**
* Read scores for specific mode.
*/
@NotNull
public static Map<UUID, Score> readScores(@NotNull String mode) {
if (!isConnected()) {
return Map.of();
}
String tableName = SQLQueryBuilder.getTableName(mode);
String query = SQLQueryBuilder.selectScores(tableName);
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query);
ResultSet rs = stmt.executeQuery()) {
return SQLDataMapper.mapScores(rs);
} catch (SQLException e) {
logger.severe("Failed to read scores for mode " + mode + ": " + e.getMessage());
return Map.of();
}
}
/**
* Write scores for specific mode.
*/
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
// Table name is from admin config — safe to format.
String sql = """
INSERT INTO `%s` (uuid, name, time, difficulty, score)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
time = VALUES(time),
difficulty = VALUES(difficulty),
score = VALUES(score);
""".formatted(getTableName(mode));
new HashMap<>(scores).forEach((uuid, score) ->
queryExecutor.executeUpdate(sql,
uuid.toString(),
score.name(),
score.time(),
score.difficulty(),
score.score()));
if (!isConnected() || scores.isEmpty()) {
return;
}
String tableName = SQLQueryBuilder.getTableName(mode);
String query = SQLQueryBuilder.upsertScore(tableName);
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
for (Map.Entry<UUID, Score> entry : scores.entrySet()) {
SQLDataMapper.setScoreParameters(stmt, entry.getValue(), entry.getKey());
stmt.addBatch();
}
stmt.executeBatch();
logger.info("Wrote " + scores.size() + " scores for mode: " + mode);
} catch (SQLException e) {
logger.severe("Failed to write scores for mode " + mode + ": " + e.getMessage());
}
}
// ── Player settings ───────────────────────────────────────────────────────
/**
* Reads player settings from the options table.
*
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
* Read player data from database.
*/
public static void readPlayer(@NotNull ParkourPlayer player) {
// Table name from admin config — safe. UUID bound as parameter below.
String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX);
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) {
player.setSettings(new HashMap<>());
return;
}
// Bind UUID as parameter — no injection possible
stmt.setString(1, player.getUUID().toString());
if (!isConnected()) {
return;
}
String query = SQLQueryBuilder.selectPlayer();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, player.player.getUniqueId().toString());
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
player.setSettings(new HashMap<>());
return;
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
if (data != null) {
SQLDataMapper.applyPlayerData(player, data);
}
Map<String, Object> settings = new HashMap<>();
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
try {
settings.put(key, rs.getObject(key));
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading SQL column %s for %s - %s"
.formatted(key, player.getName(), ex.getMessage()));
}
}
player.setSettings(settings);
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
} catch (SQLException e) {
logger.severe("Failed to read player data: " + e.getMessage());
}
}
/**
* Writes player settings to the options table.
*
* <p>All 12 player-controlled values (style, locale, rewards, etc.) are bound
* as {@code ?} parameters. None of them are interpolated into the SQL string.</p>
* Write player data to database.
*/
public static void writePlayer(@NotNull ParkourPlayer player) {
DecimalFormat df = new DecimalFormat("#.######",
DecimalFormatSymbols.getInstance(Locale.ENGLISH));
double schemDiff = df.format(player.schematicDifficulty) != null
? player.schematicDifficulty : 0.0;
// Table name from admin config — safe to format.
String sql = """
INSERT INTO `%soptions`
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
showScoreboard, selectedTime, collectedRewards, locale,
schematicDifficulty, sound)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
style = VALUES(style),
blockLead = VALUES(blockLead),
useParticles = VALUES(useParticles),
useSpecial = VALUES(useSpecial),
showFallMsg = VALUES(showFallMsg),
showScoreboard = VALUES(showScoreboard),
selectedTime = VALUES(selectedTime),
collectedRewards = VALUES(collectedRewards),
locale = VALUES(locale),
schematicDifficulty = VALUES(schematicDifficulty),
sound = VALUES(sound);
""".formatted(Option.SQL_PREFIX);
queryExecutor.executeUpdate(sql,
player.getUUID().toString(), // 1 uuid
player.style, // 2 style
player.blockLead, // 3 blockLead
player.particles, // 4 useParticles
player.useSpecialBlocks, // 5 useSpecial
player.showFallMessage, // 6 showFallMsg
player.showScoreboard, // 7 showScoreboard
player.selectedTime, // 8 selectedTime
String.join(",", player.collectedRewards), // 9 collectedRewards
player.locale, // 10 locale
schemDiff, // 11 schematicDifficulty
player.sound // 12 sound
);
}
// ── Internals ─────────────────────────────────────────────────────────────
/**
* Table name is built from the admin-configured SQL prefix and the internal
* mode name (e.g. "default", "speedrun"). Neither is user-supplied.
*/
private static String getTableName(String mode) {
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
}
private static void connect() {
connectionManager.connect();
migrationManager.initializeDatabase();
for (String sql : pendingTableCreations) {
queryExecutor.executeStaticUpdate(sql);
if (!isConnected()) {
return;
}
String query = SQLQueryBuilder.upsertPlayer();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
SQLDataMapper.setPlayerParameters(stmt, player);
stmt.executeUpdate();
} catch (SQLException e) {
logger.severe("Failed to write player data: " + e.getMessage());
}
pendingTableCreations.clear();
}
}
private static void createTables(@NotNull String mode) throws SQLException {
String tableName = SQLQueryBuilder.getTableName(mode);
try (Connection conn = connectionManager.getConnection()) {
// Create scores table
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
stmt.executeUpdate();
}
// Create players table
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
stmt.executeUpdate();
}
}
}
}

View file

@ -12,17 +12,18 @@ public final class RandomStyle implements Style {
public RandomStyle(String name, List<Material> materials) {
this.name = name;
this.materials = materials;
this.materials = (materials == null || materials.isEmpty())
? List.of(Material.STONE)
: List.copyOf(materials);
}
@Override
public Material getNext() {
return materials.get(ThreadLocalRandom.current().nextInt(materials.size()));
}
@Override
public String getName() {
return name;
}
}
}

View file

@ -0,0 +1,111 @@
package dev.loki.loparkour.util;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
/**
* Parses {@link Material} names from config (modern IDs, registry, legacy aliases).
* On older servers (e.g. 1.21.1), colored {@code *_concrete_slab} / {@code *_terracotta_slab}
* may be absent — those map to the matching full block.
*/
public final class Materials {
private Materials() {}
@Nullable
public static Material parse(@Nullable String name) {
if (name == null) {
return null;
}
String raw = name.trim();
if (raw.isEmpty()) {
return null;
}
String key = raw.toLowerCase(Locale.ROOT).replace(' ', '_');
Material m = Material.matchMaterial(key, false);
if (m != null) {
return m;
}
m = fromRegistry(key);
if (m != null) {
return m;
}
m = Material.matchMaterial(raw.toUpperCase(Locale.ROOT), false);
if (m != null) {
return m;
}
int colon = key.indexOf(':');
if (colon >= 0) {
String id = key.substring(colon + 1);
m = Material.matchMaterial(id, false);
if (m != null) {
return m;
}
m = fromRegistry(id);
if (m != null) {
return m;
}
}
m = coloredSlabFallback(key);
if (m != null) {
return m;
}
m = Material.matchMaterial(key, true);
if (m != null) {
return m;
}
return Material.getMaterial(raw.toUpperCase(Locale.ROOT));
}
@Nullable
private static Material fromRegistry(String minecraftId) {
try {
NamespacedKey nk = NamespacedKey.minecraft(minecraftId);
return Registry.MATERIAL.get(nk);
} catch (IllegalArgumentException | NoSuchFieldError | NoClassDefFoundError ignored) {
return null;
}
}
/**
* 1.21.2+ slab types are missing on 1.21.1 — use the solid concrete / terracotta block instead.
*/
@Nullable
private static Material coloredSlabFallback(String key) {
if (key.endsWith("_concrete_slab")) {
String blockId = key.substring(0, key.length() - "_concrete_slab".length()) + "_concrete";
Material m = Material.matchMaterial(blockId, false);
if (m != null) {
return m;
}
return fromRegistry(blockId);
}
if (key.endsWith("_terracotta_slab")) {
String blockId = key.substring(0, key.length() - "_terracotta_slab".length()) + "_terracotta";
Material m = Material.matchMaterial(blockId, false);
if (m != null) {
return m;
}
return fromRegistry(blockId);
}
return null;
}
@NotNull
public static Material parseOr(@Nullable String name, @NotNull Material fallback) {
Material m = parse(name);
return m != null ? m : fallback;
}
}

View file

@ -15,17 +15,14 @@ public class Menu {
}
public Menu item(int slot, Item item) {
// TODO: Implement with LoLib GUI
return this;
}
public Menu distributeRowsEvenly() {
// TODO: Implement
return this;
}
public void open(Player player) {
// TODO: Implement with LoLib GUI
player.sendMessage("§cМеню временно недоступно во время миграции");
player.sendMessage("§c" + title + " (" + rows + " рядов) — меню временно недоступно (миграция GUI).");
}
}

View file

@ -18,36 +18,29 @@ public class PagedMenu {
}
public PagedMenu addToDisplay(List<Item> items) {
// TODO: Implement with LoLib GUI
return this;
}
public PagedMenu item(int slot, Item item) {
// TODO: Implement with LoLib GUI
return this;
}
public PagedMenu nextPage(int slot, Item item) {
// TODO: Implement with LoLib GUI
return this;
}
public PagedMenu prevPage(int slot, Item item) {
// TODO: Implement with LoLib GUI
return this;
}
public void page(int direction) {
// TODO: Implement with LoLib GUI
}
public List<Object> getTotalToDisplay() {
// TODO: Implement with LoLib GUI
return new ArrayList<>();
}
public void open(Player player) {
// TODO: Implement with LoLib GUI
player.sendMessage("§cМеню временно недоступно во время миграции");
player.sendMessage("§c" + title + " (" + rows + " рядов, " + items.size() + " пунктов) — меню временно недоступно.");
}
}

View file

@ -9,12 +9,10 @@ import java.util.function.Consumer;
public class SliderItem {
public SliderItem initial(int value) {
// TODO: Implement with LoLib GUI
return this;
}
public SliderItem add(int index, Item item, Consumer<InventoryClickEvent> handler) {
// TODO: Implement with LoLib GUI
return this;
}
}

View file

@ -1,9 +1,7 @@
package dev.loki.loparkour.world;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.Locations;
import org.bukkit.Location;
import java.util.HashMap;

View file

@ -1,15 +1,12 @@
package dev.loki.loparkour.world;
import dev.lolib.worlds.VoidGenerator;
import org.bukkit.Bukkit;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.lolib.worlds.VoidGenerator;
import org.bukkit.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
@ -25,6 +22,11 @@ public class World {
*/
public static void create() {
name = Config.CONFIG.getString("world.name");
if (!isValidWorldFolderName(name)) {
LoParkour.getPlugin().getLogger().severe(
"Invalid world.name in config (use only letters, digits, '_' and '-', no path separators).");
return;
}
var world = Bukkit.getWorld(name);
@ -56,8 +58,9 @@ public class World {
.environment(org.bukkit.World.Environment.NORMAL);
world = Bukkit.createWorld(creator);
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to create the parkour world - delete the parkour world folder and restart the server - " + ex.getMessage());
} catch (IllegalArgumentException | IllegalStateException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to create the parkour world — check world.name — " + ex.getMessage());
}
}
@ -93,32 +96,63 @@ public class World {
}
LoParkour.log("Deleting world");
getWorld().getPlayers().forEach(player -> player.kickPlayer("Server is restarting"));
org.bukkit.World w = getWorld();
if (w != null) {
w.getPlayers().forEach(player -> player.kickPlayer("Server is restarting"));
}
deleteWorld();
}
private static void deleteWorld() {
if (!isValidWorldFolderName(name)) {
LoParkour.getPlugin().getLogger().severe("Refusing to delete world: invalid world name.");
return;
}
LoParkour.log("Deleting Spigot world");
File file = new File(name);
// world has already been deleted
if (!file.exists()) {
File folder = worldFolder();
if (!folder.exists()) {
return;
}
Bukkit.unloadWorld(name, false);
try (Stream<Path> files = Files.walk(file.toPath())) {
try (Stream<Path> files = Files.walk(folder.toPath())) {
files.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to delete the parkour world" + " - " + ex.getMessage());
} catch (IOException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to delete the parkour world — " + ex.getMessage());
}
}
private static File worldFolder() {
return new File(Bukkit.getWorldContainer(), name);
}
/**
* Prevents path traversal and accidental deletion outside the server world container.
*/
static boolean isValidWorldFolderName(String n) {
if (n == null || n.isBlank() || n.length() > 64) {
return false;
}
if (n.contains("..") || n.contains("/") || n.contains("\\")) {
return false;
}
for (int i = 0; i < n.length(); i++) {
char c = n.charAt(i);
if (Character.isLetterOrDigit(c) || c == '_' || c == '-') {
continue;
}
return false;
}
return true;
}
/**
* @return the name of the parkour world.
*/