refactor: decompose god-classes, add lint gates, restructure packages

- Decompose all 11 god-classes (>200 lines) into facade + collaborators
  (command/, schematic/, config/, generator/, mode/, papi/, adaptive/,
  storage/, player/, leaderboard/) — every file is now <=200 lines
- Add PMD 7 (report-only) + stricter checkstyle rules (NPath, IllegalCatch,
  NestedDepth, Whitespace, MissingOverride); fix real bugs found
  (printStackTrace, resource leaks in ConfigLoader/SQLStatsStorage)
- docs/ARCHITECTURE.md: target structure + 'one folder = one purpose' rule
- Pilot restructure: command/core|util/, mode/impl/solo|multi|barrier/,
  hook/papi/resolver/; cross-package classes made public
- TODO.md: track warning cleanup (132-138) + restructure progress
This commit is contained in:
loki5512344 2026-08-05 18:33:21 +02:00
parent 1d57972038
commit 81788e71ed
Signed by: boba
GPG key ID: 253067914055423B
70 changed files with 3428 additions and 1701 deletions

View file

@ -18,6 +18,7 @@ import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.ArrayList;
import java.util.logging.Level;
/**
* Main class of LoParkour
@ -112,8 +113,7 @@ public final class LoParkour extends LoPlugin {
Storage.close();
World.delete();
} catch (Throwable t) {
getLogger().severe("Error during plugin shutdown: " + t.getMessage());
t.printStackTrace();
getLogger().log(Level.SEVERE, "Error during plugin shutdown: " + t.getMessage(), t);
}
}
}

View file

@ -0,0 +1,48 @@
package dev.loki.loparkour.adaptive.core;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Aggregates timing and position between parkour landings.
*
* Responsibilities:
* - Track the last landed block per player
* - Track the timestamp of the last landing per player
* - Provide the timing window between consecutive landings
*
* Thread-safe: Uses ConcurrentHashMap for player caches.
*/
class JumpTimingTracker {
private final Map<UUID, Block> lastBlockCache;
private final Map<UUID, Long> lastLandTimeCache;
JumpTimingTracker() {
this.lastBlockCache = new ConcurrentHashMap<>();
this.lastLandTimeCache = new ConcurrentHashMap<>();
}
@Nullable
Long lastLandTime(@NotNull UUID playerUuid) {
return lastLandTimeCache.get(playerUuid);
}
void markLand(@NotNull UUID playerUuid, long timeMillis) {
lastLandTimeCache.put(playerUuid, timeMillis);
}
void updateLastBlock(@NotNull UUID playerUuid, @NotNull Block block) {
lastBlockCache.put(playerUuid, block);
}
void unload(@NotNull UUID playerUuid) {
lastBlockCache.remove(playerUuid);
lastLandTimeCache.remove(playerUuid);
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.generator.jump.calculation.JumpType;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Collects raw generator events into player metrics.
*
* Responsibilities:
* - Record successful block landings with timing
* - Record jump events with type and timing
* - Detect near-miss jumps (distance > 3.5 blocks)
* - Persist metrics when a player falls
*/
class MetricEventCollector {
private static final double NEAR_MISS_THRESHOLD = 3.5;
private final MetricsCacheStore cacheStore;
private final JumpTimingTracker timingTracker;
MetricEventCollector(@NotNull MetricsCacheStore cacheStore, @NotNull JumpTimingTracker timingTracker) {
this.cacheStore = cacheStore;
this.timingTracker = timingTracker;
}
void onBlockLand(@NotNull Player player, @NotNull Block block, double distance) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
long currentTime = System.currentTimeMillis();
Long lastTime = timingTracker.lastLandTime(uuid);
if (lastTime != null) {
metrics.recordJump("normal", currentTime - lastTime);
}
timingTracker.markLand(uuid, currentTime);
if (distance > NEAR_MISS_THRESHOLD) {
metrics.recordNearMiss();
}
timingTracker.updateLastBlock(uuid, block);
}
void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance) {
onBlockLand(player, block, distance);
}
void onPlayerFall(@NotNull Player player) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
cacheStore.save(metrics);
}
void onJump(@NotNull Player player, @NotNull JumpType jumpType) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
long currentTime = System.currentTimeMillis();
Long lastTime = timingTracker.lastLandTime(uuid);
long timeDiff = (lastTime != null) ? (currentTime - lastTime) : 0;
metrics.recordJump(jumpType.name().toLowerCase(), timeDiff);
}
void unload(@NotNull UUID playerUuid) {
timingTracker.unload(playerUuid);
}
}

View file

@ -0,0 +1,100 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.storage.StatsRepository;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Stores and prunes the cached player metrics.
*
* Responsibilities:
* - Load or create metrics per player
* - Persist metrics to the repository
* - Remove players on unload
* - Schedule periodic auto-save
*
* Thread-safe: Uses ConcurrentHashMap for player metrics cache.
*/
class MetricsCacheStore {
private static final long AUTO_SAVE_INTERVAL_TICKS = 20 * 60 * 5; // 5 minutes
private final Plugin plugin;
private final StatsRepository repository;
private final Map<UUID, PlayerMetrics> metricsCache;
private BukkitRunnable autoSaveTask;
MetricsCacheStore(@NotNull Plugin plugin, @NotNull StatsRepository repository) {
this.plugin = plugin;
this.repository = repository;
this.metricsCache = new ConcurrentHashMap<>();
}
@NotNull
PlayerMetrics getOrCreate(@NotNull UUID playerUuid) {
return metricsCache.computeIfAbsent(playerUuid, this::loadOrCreate);
}
private @NotNull PlayerMetrics loadOrCreate(@NotNull UUID playerUuid) {
PlayerMetrics loaded = repository.loadMetrics(playerUuid);
if (loaded != null) {
return loaded;
}
return new PlayerMetrics(playerUuid);
}
void save(@NotNull UUID playerUuid) {
PlayerMetrics metrics = metricsCache.get(playerUuid);
if (metrics != null) {
repository.saveMetrics(metrics);
}
}
void save(@NotNull PlayerMetrics metrics) {
repository.saveMetrics(metrics);
}
void saveAll() {
for (PlayerMetrics metrics : metricsCache.values()) {
repository.saveMetrics(metrics);
}
}
void unload(@NotNull UUID playerUuid) {
save(playerUuid);
metricsCache.remove(playerUuid);
}
int size() {
return metricsCache.size();
}
void startAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
}
autoSaveTask = new BukkitRunnable() {
@Override
public void run() {
saveAll();
}
};
autoSaveTask.runTaskTimerAsynchronously(plugin, AUTO_SAVE_INTERVAL_TICKS, AUTO_SAVE_INTERVAL_TICKS);
}
void stopAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
autoSaveTask = null;
}
}
}

View file

@ -7,13 +7,10 @@ import dev.loki.loparkour.generator.jump.calculation.JumpType;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Collects and manages player performance metrics.
@ -26,19 +23,16 @@ import java.util.concurrent.ConcurrentHashMap;
* - Auto-save metrics every 5 minutes
*
* Thread-safe: Uses ConcurrentHashMap for player metrics cache.
*
* Facade over the metrics subsystem:
* - {@link MetricsCacheStore} handles cache storage, pruning, and persistence
* - {@link MetricEventCollector} handles raw generator events
* - {@link JumpTimingTracker} aggregates timing windows between landings
*/
public class MetricsCollector implements GeneratorEventListener {
private static final double NEAR_MISS_THRESHOLD = 3.5;
private static final long AUTO_SAVE_INTERVAL_TICKS = 20 * 60 * 5; // 5 minutes
private final Plugin plugin;
private final StatsRepository repository;
private final Map<UUID, PlayerMetrics> metricsCache;
private final Map<UUID, Block> lastBlockCache;
private final Map<UUID, Long> lastBlockTimeCache;
private BukkitRunnable autoSaveTask;
private final MetricsCacheStore cacheStore;
private final MetricEventCollector eventCollector;
/**
* Create a new MetricsCollector.
@ -47,11 +41,8 @@ public class MetricsCollector implements GeneratorEventListener {
* @param repository The repository for persisting metrics
*/
public MetricsCollector(@NotNull Plugin plugin, @NotNull StatsRepository repository) {
this.plugin = plugin;
this.repository = repository;
this.metricsCache = new ConcurrentHashMap<>();
this.lastBlockCache = new ConcurrentHashMap<>();
this.lastBlockTimeCache = new ConcurrentHashMap<>();
this.cacheStore = new MetricsCacheStore(plugin, repository);
this.eventCollector = new MetricEventCollector(cacheStore, new JumpTimingTracker());
}
/**
@ -59,18 +50,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Should be called when the collector is initialized.
*/
public void startAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
}
autoSaveTask = new BukkitRunnable() {
@Override
public void run() {
saveAllMetrics();
}
};
autoSaveTask.runTaskTimerAsynchronously(plugin, AUTO_SAVE_INTERVAL_TICKS, AUTO_SAVE_INTERVAL_TICKS);
cacheStore.startAutoSave();
}
/**
@ -78,10 +58,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Should be called when the plugin is disabled.
*/
public void stopAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
autoSaveTask = null;
}
cacheStore.stopAutoSave();
}
/**
@ -92,15 +69,7 @@ public class MetricsCollector implements GeneratorEventListener {
*/
@NotNull
public PlayerMetrics getMetrics(@NotNull UUID playerUuid) {
return metricsCache.computeIfAbsent(playerUuid, uuid -> {
// Try to load from repository
PlayerMetrics loaded = repository.loadMetrics(uuid);
if (loaded != null) {
return loaded;
}
// Create new metrics if not found
return new PlayerMetrics(uuid);
});
return cacheStore.getOrCreate(playerUuid);
}
/**
@ -112,26 +81,7 @@ public class MetricsCollector implements GeneratorEventListener {
* @param distance The distance of the jump
*/
public void onBlockLand(@NotNull Player player, @NotNull Block block, double distance) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Calculate time since last block
long currentTime = System.currentTimeMillis();
Long lastTime = lastBlockTimeCache.get(uuid);
if (lastTime != null) {
long timeDiff = currentTime - lastTime;
// Record jump with timing (use "normal" as default type)
metrics.recordJump("normal", timeDiff);
}
lastBlockTimeCache.put(uuid, currentTime);
// Detect near-miss (long jump that succeeded)
if (distance > NEAR_MISS_THRESHOLD) {
metrics.recordNearMiss();
}
// Update last block
lastBlockCache.put(uuid, block);
eventCollector.onBlockLand(player, block, distance);
}
@Override
@ -141,30 +91,17 @@ public class MetricsCollector implements GeneratorEventListener {
@Override
public void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance) {
onBlockLand(player, block, distance);
eventCollector.onPlayerScore(player, block, distance);
}
@Override
public void onPlayerFall(@NotNull Player player, @Nullable Block lastBlock) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Save metrics on fall (important event)
repository.saveMetrics(metrics);
eventCollector.onPlayerFall(player);
}
@Override
public void onJump(@NotNull Player player, @NotNull JumpType jumpType) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Calculate time since last block
long currentTime = System.currentTimeMillis();
Long lastTime = lastBlockTimeCache.get(uuid);
long timeDiff = (lastTime != null) ? (currentTime - lastTime) : 0;
// Record jump with type and timing
metrics.recordJump(jumpType.name().toLowerCase(), timeDiff);
eventCollector.onJump(player, jumpType);
}
/**
@ -173,10 +110,7 @@ public class MetricsCollector implements GeneratorEventListener {
* @param playerUuid The player's UUID
*/
public void saveMetrics(@NotNull UUID playerUuid) {
PlayerMetrics metrics = metricsCache.get(playerUuid);
if (metrics != null) {
repository.saveMetrics(metrics);
}
cacheStore.save(playerUuid);
}
/**
@ -184,9 +118,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Called by auto-save task and on plugin disable.
*/
public void saveAllMetrics() {
for (PlayerMetrics metrics : metricsCache.values()) {
repository.saveMetrics(metrics);
}
cacheStore.saveAll();
}
/**
@ -196,11 +128,8 @@ public class MetricsCollector implements GeneratorEventListener {
* @param playerUuid The player's UUID
*/
public void unloadPlayer(@NotNull UUID playerUuid) {
// Save before removing
saveMetrics(playerUuid);
metricsCache.remove(playerUuid);
lastBlockCache.remove(playerUuid);
lastBlockTimeCache.remove(playerUuid);
cacheStore.unload(playerUuid);
eventCollector.unload(playerUuid);
}
/**
@ -209,6 +138,6 @@ public class MetricsCollector implements GeneratorEventListener {
* @return The cache size
*/
public int getCacheSize() {
return metricsCache.size();
return cacheStore.size();
}
}

View file

@ -40,13 +40,13 @@ public class SQLStatsStorage implements StatsRepository {
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, playerUuid.toString());
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
double rating = rs.getDouble("skill_rating");
int sessions = rs.getInt("sessions_count");
double confidence = Math.min(1.0, sessions * 0.05);
return new SkillRating(playerUuid, rating, confidence, sessions);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
double rating = rs.getDouble("skill_rating");
int sessions = rs.getInt("sessions_count");
double confidence = Math.min(1.0, sessions * 0.05);
return new SkillRating(playerUuid, rating, confidence, sessions);
}
}
} catch (SQLException e) {
LoParkour.getPlugin().getLogger().warning("Failed to load skill rating: " + e.getMessage());

View file

@ -3,7 +3,7 @@ package dev.loki.loparkour.bootstrap;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.LoParkourCommand;
import dev.loki.loparkour.command.core.LoParkourCommand;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
@ -13,13 +13,13 @@ import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
import dev.loki.loparkour.listener.schematic.SchematicWandListener;
import dev.loki.loparkour.listener.player.PlayerConnectionListener;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.impl.CoopMode;
import dev.loki.loparkour.mode.impl.DefaultMode;
import dev.loki.loparkour.mode.impl.GravityShiftMode;
import dev.loki.loparkour.mode.impl.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.RaceMode;
import dev.loki.loparkour.mode.impl.SpectatorMode;
import dev.loki.loparkour.mode.impl.SpeedrunMode;
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.multi.CoopMode;
import dev.loki.loparkour.mode.impl.multi.RaceMode;
import dev.loki.loparkour.mode.impl.multi.SpectatorMode;
import dev.loki.loparkour.mode.impl.solo.DefaultMode;
import dev.loki.loparkour.mode.impl.solo.GravityShiftMode;
import dev.loki.loparkour.mode.impl.solo.SpeedrunMode;
import dev.loki.loparkour.reward.core.Rewards;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.world.core.World;

View file

@ -1,302 +0,0 @@
package dev.loki.loparkour.command;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
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}.
*/
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private static final List<String> SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0");
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload"
);
private final PlayerCommandHandler player = new PlayerCommandHandler();
private final AdminCommandHandler admin = new AdminCommandHandler(player);
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
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);
case 4 -> handle4(args[0], args[1], args[2], args[3], sender, p);
default -> Locales.send(sender, "commands.too_many_args");
}
return true;
} catch (Throwable t) {
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
Locales.send(sender, "commands.error");
return true;
}
}
// ── 2-arg routing ──────────────────────────────────────────────────────────
private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) {
// Admin commands that don't need a player
switch (a1.toLowerCase()) {
case "forcejoin", "forceleave", "reset", "recoverinventory" -> {
admin.handle(a1, a2, sender, p);
return;
}
default -> {
}
}
if (p == null) {
return;
}
if ("create".equalsIgnoreCase(a1) && p.hasPermission(ParkourOption.ADMIN.permission)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a2, sender, p, player);
return;
}
switch (a1.toLowerCase()) {
case "join" -> handleJoin(a2, sender, p);
case "leaderboard" -> handleLeaderboard(a2, sender, p);
case "schematic" -> {
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
send(sender, Locales.getString(p, "other.no_do"));
return;
}
SchematicCommandHandler.handleSubcommand(a2, sender, p, player);
}
default -> {
}
}
}
// ── 3-arg routing ──────────────────────────────────────────────────────────
private void handle3(String a1, String a2, String a3, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if ("schematic".equalsIgnoreCase(a1)) {
if ("create".equalsIgnoreCase(a2)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a3, sender, p, player);
return;
}
SchematicCommandHandler.handleSubcommandWithName(a2, a3, sender, p);
}
}
private void handle4(String a1, String a2, String a3, String a4, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if (!"schematic".equalsIgnoreCase(a1) || !"create".equalsIgnoreCase(a2)) {
return;
}
SchematicCommandHandler.handleCreateWithDifficulty(a3, a4, sender, p, player);
}
// ── join logic ─────────────────────────────────────────────────────────────
private void handleJoin(String arg, CommandSender sender, Player p) {
if (!player.cooldown(sender, "join", 2500) || !ParkourOption.JOIN.mayPerform(p)) {
return;
}
Mode mode = Registry.getMode(arg);
if (mode != null) {
mode.create(p);
return;
}
Player other = Bukkit.getPlayer(arg);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.unknown_player"));
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(other);
if (pp == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.not_playing"));
return;
}
ParkourUser user = ParkourUser.getUser(p);
Session session = pp.session;
if (user != null && user.session == session) {
return;
}
if (session.isAcceptingPlayers()) {
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);
}
}
// ── leaderboard logic ──────────────────────────────────────────────────────
private void handleLeaderboard(String arg, CommandSender sender, Player p) {
if (!ParkourOption.LEADERBOARDS.mayPerform(p)) {
send(sender, Locales.getString(p, "other.no_do"));
return;
}
Mode mode = Registry.getMode(arg.toLowerCase());
if (mode == null) {
Menus.LEADERBOARDS.open(p);
} else {
Menus.SINGLE_LEADERBOARD.open(p, mode, Leaderboard.Sort.SCORE);
}
}
// ── tab complete ───────────────────────────────────────────────────────────
@Override
@SuppressWarnings("checkstyle:CyclomaticComplexity")
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
List<String> completions = new ArrayList<>();
if (args.length == 1) {
if (ParkourOption.JOIN.mayPerform(sender)) {
completions.add("join");
completions.add("leave");
}
if (ParkourOption.MAIN.mayPerform(sender)) {
completions.add("menu");
}
if (ParkourOption.PLAY.mayPerform(sender)) {
completions.add("play");
}
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
completions.add("leaderboard");
}
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(List.of("schematic", "create", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
}
return filter(args[0], completions);
}
if (args.length == 2) {
String a1 = args[0].toLowerCase();
if ("reset".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if ("join".equals(a1) && ParkourOption.JOIN.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if ("leaderboard".equals(a1) && ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
} else if ("schematic".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_SUBCOMMANDS);
} else if ("create".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
} else if (("forcejoin".equals(a1) || "forceleave".equals(a1)) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
if ("forcejoin".equals(a1)) {
completions.add("nearest");
}
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
} else if ("recoverinventory".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(args[1], completions);
}
if (args.length == 3 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
String a1 = args[0].toLowerCase();
String a2 = args[1].toLowerCase();
if ("schematic".equals(a1)) {
if ("paste".equals(a2)) {
addLoadedSchematicIds(completions);
} else if ("create".equals(a2)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
}
return filter(args[2], completions);
}
if (args.length == 4 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
String a1 = args[0].toLowerCase();
String a2 = args[1].toLowerCase();
if ("schematic".equals(a1) && "create".equals(a2)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(args[3], completions);
}
return Collections.emptyList();
}
private static void addLoadedSchematicIds(@NotNull List<String> completions) {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return;
}
for (String id : manager.getAll().keySet()) {
completions.add(id);
String shortId = SchematicManager.configKey(id);
if (!shortId.equalsIgnoreCase(id)) {
completions.add(shortId);
}
}
}
// ── util ───────────────────────────────────────────────────────────────────
private List<String> filter(String input, List<String> options) {
String lower = input.toLowerCase();
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
}
private void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -1,28 +1,13 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.player.data.InventoryData;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Handles admin-only /LoParkour sub-commands:
* forcejoin, forceleave, reset, recoverinventory.
@ -30,9 +15,17 @@ import java.util.UUID;
public class AdminCommandHandler {
private final PlayerCommandHandler base;
private final ForceJoinExecutor forceJoin;
private final ForceLeaveExecutor forceLeave;
private final ResetExecutor reset;
private final RecoverInventoryExecutor recoverInventory;
public AdminCommandHandler(PlayerCommandHandler base) {
this.base = base;
this.forceJoin = new ForceJoinExecutor(base);
this.forceLeave = new ForceLeaveExecutor(base);
this.reset = new ResetExecutor(base);
this.recoverInventory = new RecoverInventoryExecutor(base);
}
public void handle(@NotNull String arg1, @NotNull String arg2,
@ -42,173 +35,37 @@ public class AdminCommandHandler {
return;
}
dispatchAdmin(arg1, arg2, sender);
dispatchSchematic(arg1, arg2, sender, player);
}
private void dispatchAdmin(@NotNull String arg1, @NotNull String arg2, @NotNull CommandSender sender) {
switch (arg1.toLowerCase()) {
case "forcejoin" -> handleForceJoin(arg2, sender);
case "forceleave" -> handleForceLeave(arg2, sender);
case "reset" -> handleReset(arg2, sender);
case "recoverinventory" -> handleRecoverInventory(arg2, sender);
case "forcejoin" -> {
forceJoin.execute(arg2, sender);
}
case "forceleave" -> {
forceLeave.execute(arg2, sender);
}
case "reset" -> {
reset.execute(arg2, sender);
}
case "recoverinventory" -> {
recoverInventory.execute(arg2, sender);
}
default -> {
/* unknown admin subcommand: handle() falls through to schematic */
}
}
}
private void dispatchSchematic(@NotNull String arg1, @NotNull String arg2,
@NotNull CommandSender sender, @Nullable Player player) {
if (player == null) {
return;
}
if ("schematic".equals(arg1)) {
SchematicCommandHandler.handleSubcommand(arg2, sender, player, base);
}
}
// ── forcejoin ──────────────────────────────────────────────────────────────
private void handleForceJoin(String target, CommandSender sender) {
if (!base.cooldown(sender, "forcejoin", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_everyone"));
return;
}
if ("nearest".equalsIgnoreCase(target)) {
Player closest = findNearest(sender);
if (closest == null) {
return;
}
Modes.DEFAULT.create(closest);
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_player").formatted(closest.getName()));
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
Modes.DEFAULT.create(other);
}
// ── forceleave ─────────────────────────────────────────────────────────────
private void handleForceLeave(String target, CommandSender sender) {
if (!base.cooldown(sender, "forceleave", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_leave_everyone"));
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
ParkourUser user = ParkourUser.getUser(other);
if (user == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_playing"));
return;
}
ParkourUser.leave(user);
}
// ── reset ──────────────────────────────────────────────────────────────────
private void handleReset(String target, CommandSender sender) {
if (!base.cooldown(sender, "reset", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(lb -> {
lb.resetAll();
lb.write(true);
});
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_all"));
return;
}
UUID uuid = resolveUUID(target);
String name = resolvePlayerName(target, uuid);
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(lb -> {
lb.remove(uuid);
lb.write(true);
});
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_player").formatted(name));
}
// ── recoverinventory ───────────────────────────────────────────────────────
private void handleRecoverInventory(String target, CommandSender sender) {
if (!base.cooldown(sender, "recoverinventory", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
new InventoryData(other).load(result -> {
if (result != null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory").formatted(other.getName()));
} else {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory_missing").formatted(other.getName()));
}
});
}
// ── helpers ────────────────────────────────────────────────────────────────
private @Nullable Player findNearest(CommandSender sender) {
Location from = sender instanceof Player p ? p.getLocation()
: sender instanceof BlockCommandSender b ? b.getBlock().getLocation()
: null;
if (from == null || from.getWorld() == null) {
return null;
}
return from.getWorld().getPlayers().stream()
.min((a, b) -> Double.compare(a.getLocation().distance(from), b.getLocation().distance(from)))
.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("-")) {
try {
return UUID.fromString(input);
} catch (IllegalArgumentException ignored) {
// fall through to offline lookup by name
}
}
return Bukkit.getOfflinePlayer(input).getUniqueId();
}
private String resolvePlayerName(String input, UUID uuid) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getName();
}
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
return op.getName() != null ? op.getName() : input;
}
private void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -0,0 +1,14 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
final class AdminResponses {
private AdminResponses() {
}
static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -0,0 +1,61 @@
package dev.loki.loparkour.command.admin;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.UUID;
final class AdminTargetResolver {
private AdminTargetResolver() {
}
static Player findNearest(CommandSender sender) {
Location from = sender instanceof Player p ? p.getLocation()
: sender instanceof BlockCommandSender b ? b.getBlock().getLocation()
: null;
if (from == null || from.getWorld() == null) {
return null;
}
return from.getWorld().getPlayers().stream()
.min((a, b) -> Double.compare(a.getLocation().distance(from), b.getLocation().distance(from)))
.orElse(null);
}
@SuppressWarnings("deprecation")
static UUID resolveUUID(String input) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getUniqueId();
}
if (input.contains("-")) {
UUID parsed = tryParseUuid(input);
if (parsed != null) {
return parsed;
}
}
return Bukkit.getOfflinePlayer(input).getUniqueId();
}
private static UUID tryParseUuid(String input) {
try {
return UUID.fromString(input);
} catch (IllegalArgumentException ignored) {
return null;
}
}
static String resolvePlayerName(String input, UUID uuid) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getName();
}
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
return op.getName() != null ? op.getName() : input;
}
}

View file

@ -0,0 +1,56 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.mode.base.Modes;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class ForceJoinExecutor {
private final PlayerCommandHandler base;
ForceJoinExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "forcejoin", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
joinEveryone(sender);
return;
}
if ("nearest".equalsIgnoreCase(target)) {
joinNearest(sender);
return;
}
joinByName(target, sender);
}
private void joinEveryone(CommandSender sender) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_everyone"));
}
private void joinNearest(CommandSender sender) {
Player closest = AdminTargetResolver.findNearest(sender);
if (closest == null) {
return;
}
Modes.DEFAULT.create(closest);
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_player").formatted(closest.getName()));
}
private void joinByName(String target, CommandSender sender) {
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
Modes.DEFAULT.create(other);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class ForceLeaveExecutor {
private final PlayerCommandHandler base;
ForceLeaveExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "forceleave", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
leaveEveryone(sender);
return;
}
leaveByName(target, sender);
}
private void leaveEveryone(CommandSender sender) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_leave_everyone"));
}
private void leaveByName(String target, CommandSender sender) {
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
ParkourUser user = ParkourUser.getUser(other);
if (user == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_playing"));
return;
}
ParkourUser.leave(user);
}
}

View file

@ -0,0 +1,41 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.data.InventoryData;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class RecoverInventoryExecutor {
private final PlayerCommandHandler base;
RecoverInventoryExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "recoverinventory", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
recover(other, sender);
}
private void recover(Player other, CommandSender sender) {
new InventoryData(other).load(result -> {
if (result != null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory").formatted(other.getName()));
} else {
AdminResponses.send(sender, LoParkour.PREFIX
+ Locales.getString(sender, "admin.recover_inventory_missing").formatted(other.getName()));
}
});
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import org.bukkit.command.CommandSender;
import java.util.UUID;
import java.util.function.Consumer;
final class ResetExecutor {
private final PlayerCommandHandler base;
ResetExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "reset", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
resetEveryone(sender);
return;
}
resetPlayer(target, sender);
}
private void resetEveryone(CommandSender sender) {
forEachLeaderboard(lb -> {
lb.resetAll();
lb.write(true);
});
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_all"));
}
private void resetPlayer(String target, CommandSender sender) {
UUID uuid = AdminTargetResolver.resolveUUID(target);
String name = AdminTargetResolver.resolvePlayerName(target, uuid);
forEachLeaderboard(lb -> {
lb.remove(uuid);
lb.write(true);
});
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_player").formatted(name));
}
private static void forEachLeaderboard(Consumer<Leaderboard> action) {
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(action);
}
}

View file

@ -0,0 +1,111 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.JoinCommandExecutor;
import dev.loki.loparkour.command.player.LeaderboardCommandExecutor;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
class CommandRouter {
private final PlayerCommandHandler player;
private final AdminCommandHandler admin;
private final JoinCommandExecutor join;
private final LeaderboardCommandExecutor leaderboard;
CommandRouter(PlayerCommandHandler player, AdminCommandHandler admin) {
this.player = player;
this.admin = admin;
this.join = new JoinCommandExecutor();
this.leaderboard = new LeaderboardCommandExecutor();
}
void dispatch(String[] args, CommandSender sender, @Nullable Player 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);
case 4 -> handle4(args[0], args[1], args[2], args[3], sender, p);
default -> Locales.send(sender, "commands.too_many_args");
}
}
private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) {
if (isAdminCommand(a1)) {
admin.handle(a1, a2, sender, p);
return;
}
if (p == null) {
return;
}
if (canCreateSchematic(a1, p)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a2, sender, p, player);
return;
}
handlePlayerSubcommand(a1, a2, sender, p);
}
private static boolean isAdminCommand(String a1) {
return switch (a1.toLowerCase()) {
case "forcejoin", "forceleave", "reset", "recoverinventory" -> true;
default -> false;
};
}
private static boolean canCreateSchematic(String a1, Player p) {
return "create".equalsIgnoreCase(a1) && p.hasPermission(ParkourOption.ADMIN.permission);
}
private void handlePlayerSubcommand(String a1, String a2, CommandSender sender, Player p) {
switch (a1.toLowerCase()) {
case "join" -> join.join(a2, sender, p, player);
case "leaderboard" -> leaderboard.open(a2, sender, p);
case "schematic" -> {
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
return;
}
SchematicCommandHandler.handleSubcommand(a2, sender, p, player);
}
default -> {
}
}
}
private void handle3(String a1, String a2, String a3, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if ("schematic".equalsIgnoreCase(a1)) {
if ("create".equalsIgnoreCase(a2)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a3, sender, p, player);
return;
}
SchematicCommandHandler.handleSubcommandWithName(a2, a3, sender, p);
}
}
private void handle4(String a1, String a2, String a3, String a4, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if (!"schematic".equalsIgnoreCase(a1) || !"create".equalsIgnoreCase(a2)) {
return;
}
SchematicCommandHandler.handleCreateWithDifficulty(a3, a4, sender, p, player);
}
}

View file

@ -0,0 +1,181 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.SchematicManager;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class CommandTabCompleter {
private static final List<String> SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0");
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload"
);
private CommandTabCompleter() {
}
static List<String> complete(@NotNull CommandSender sender, @NotNull String[] args) {
if (args.length == 1) {
return completeFirst(sender, args[0]);
}
if (args.length == 2) {
return completeSecond(sender, args[0], args[1]);
}
if (args.length == 3 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
return completeThird(args[0], args[1], args[2]);
}
if (args.length == 4 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
return completeFourth(args[0], args[1], args[3]);
}
return Collections.emptyList();
}
private static List<String> completeFirst(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.JOIN.mayPerform(sender)) {
completions.add("join");
completions.add("leave");
}
if (ParkourOption.MAIN.mayPerform(sender)) {
completions.add("menu");
}
if (ParkourOption.PLAY.mayPerform(sender)) {
completions.add("play");
}
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
completions.add("leaderboard");
}
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(List.of("schematic", "create", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
}
return filter(input, completions);
}
private static List<String> completeSecond(CommandSender sender, String a1Raw, String input) {
String a1 = a1Raw.toLowerCase();
return switch (a1) {
case "reset" -> completeReset(sender, input);
case "join" -> completeJoin(sender, input);
case "leaderboard" -> completeLeaderboard(sender, input);
case "schematic" -> completeSchematic(sender, input);
case "create" -> completeCreate(sender, input);
case "forcejoin", "forceleave" -> completeForce(sender, a1, input);
case "recoverinventory" -> completeRecoverInventory(sender, input);
default -> Collections.emptyList();
};
}
private static List<String> completeReset(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
}
return filter(input, completions);
}
private static List<String> completeJoin(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.JOIN.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
}
return filter(input, completions);
}
private static List<String> completeLeaderboard(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
}
return filter(input, completions);
}
private static List<String> completeSchematic(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_SUBCOMMANDS);
}
return filter(input, completions);
}
private static List<String> completeCreate(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(input, completions);
}
private static List<String> completeForce(CommandSender sender, String a1, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
if ("forcejoin".equals(a1)) {
completions.add("nearest");
}
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(input, completions);
}
private static List<String> completeRecoverInventory(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(input, completions);
}
private static List<String> completeThird(String a1Raw, String a2Raw, String input) {
if (!"schematic".equalsIgnoreCase(a1Raw)) {
return Collections.emptyList();
}
return completeSchematicThird(a2Raw, input);
}
private static List<String> completeSchematicThird(String a2Raw, String input) {
List<String> completions = new ArrayList<>();
if ("paste".equalsIgnoreCase(a2Raw)) {
addLoadedSchematicIds(completions);
} else if ("create".equalsIgnoreCase(a2Raw)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(input, completions);
}
private static List<String> completeFourth(String a1Raw, String a2Raw, String input) {
if ("schematic".equalsIgnoreCase(a1Raw) && "create".equalsIgnoreCase(a2Raw)) {
return filter(input, SCHEMATIC_DIFFICULTIES);
}
return Collections.emptyList();
}
private static void addLoadedSchematicIds(@NotNull List<String> completions) {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return;
}
for (String id : manager.getAll().keySet()) {
completions.add(id);
String shortId = SchematicManager.configKey(id);
if (!shortId.equalsIgnoreCase(id)) {
completions.add(shortId);
}
}
}
private static List<String> filter(String input, List<String> options) {
String lower = input.toLowerCase();
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
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}.
*/
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private final PlayerCommandHandler player = new PlayerCommandHandler();
private final AdminCommandHandler admin = new AdminCommandHandler(player);
private final CommandRouter router = new CommandRouter(player, admin);
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
try {
Player p = sender instanceof Player ? (Player) sender : null;
router.dispatch(args, sender, p);
return true;
} catch (Throwable t) {
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
Locales.send(sender, "commands.error");
return true;
}
}
@Override
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
return CommandTabCompleter.complete(sender, args);
}
}

View file

@ -0,0 +1,65 @@
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class JoinCommandExecutor {
public void join(String arg, CommandSender sender, Player p, PlayerCommandHandler player) {
if (!player.cooldown(sender, "join", 2500) || !ParkourOption.JOIN.mayPerform(p)) {
return;
}
if (Registry.getMode(arg) instanceof Mode mode) {
mode.create(p);
return;
}
joinPlayingPlayer(arg, sender, p);
}
private void joinPlayingPlayer(String arg, CommandSender sender, Player p) {
Player other = Bukkit.getPlayer(arg);
if (other == null) {
CommandUtil.send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.unknown_player"));
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(other);
if (pp == null) {
CommandUtil.send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.not_playing"));
return;
}
joinSession(sender, p, pp.session);
}
private void joinSession(CommandSender sender, Player p, Session session) {
ParkourUser user = ParkourUser.getUser(p);
if (user != null && user.session == session) {
return;
}
if (session.isAcceptingPlayers()) {
joinAcceptingSession(p, session);
} else {
Modes.SPECTATOR.create(p, session);
}
}
private void joinAcceptingSession(Player p, Session session) {
Mode sessionMode = session.generator.getMode();
if (sessionMode instanceof MultiMode mm) {
mm.join(p, session);
} else {
Modes.SPECTATOR.create(p, session);
}
}
}

View file

@ -0,0 +1,27 @@
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class LeaderboardCommandExecutor {
public void open(String arg, CommandSender sender, Player p) {
if (!ParkourOption.LEADERBOARDS.mayPerform(p)) {
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
return;
}
Mode mode = Registry.getMode(arg.toLowerCase());
if (mode == null) {
Menus.LEADERBOARDS.open(p);
} else {
Menus.SINGLE_LEADERBOARD.open(p, mode, Leaderboard.Sort.SCORE);
}
}
}

View file

@ -2,27 +2,13 @@ package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.schematic.convert.LpschemConverter;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.create.SchematicCreator;
import dev.loki.loparkour.util.particle.ParticleUtil;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.loki.loparkour.util.world.Locations;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.BoundingBox;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
@ -38,97 +24,38 @@ public class SchematicCommandHandler {
public static final Map<Player, Location[]> SELECTIONS = new HashMap<>();
public static final NamespacedKey WAND_KEY = new NamespacedKey(LoParkour.getPlugin(), "schematic_wand");
private static ItemStack cachedWand;
@NotNull
public static ItemStack getWand() {
return buildWand().clone();
return SchematicWand.getWand();
}
public static boolean isWand(@Nullable ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return false;
}
ItemMeta meta = item.getItemMeta();
return meta != null
&& meta.getPersistentDataContainer().has(WAND_KEY, PersistentDataType.BYTE);
return SchematicWand.isWand(item);
}
@NotNull
public static Location[] getSelection(@NotNull Player player) {
Location[] sel = SELECTIONS.get(player);
if (sel == null) {
sel = new Location[]{null, null};
SELECTIONS.put(player, sel);
}
return sel;
return SelectionStore.get(player);
}
public static void setPos(@NotNull Player player, @NotNull Location loc, int index) {
Location[] updated = getSelection(player).clone();
updated[index] = loc.clone();
SELECTIONS.put(player, updated);
SelectionStore.set(player, loc, index);
}
public static void clearWandCache() {
cachedWand = null;
SchematicWand.clearCache();
}
public static void sendHelp(@NotNull Player player) {
send(player, "");
send(player, Locales.getString(player, "schematic.help.header"));
send(player, "");
send(player, Locales.getString(player, "schematic.help.line_wand"));
send(player, Locales.getString(player, "schematic.help.line_pos1"));
send(player, Locales.getString(player, "schematic.help.line_pos2"));
send(player, Locales.getString(player, "schematic.help.line_create"));
send(player, Locales.getString(player, "schematic.help.line_create_named"));
send(player, Locales.getString(player, "schematic.help.line_convert"));
send(player, Locales.getString(player, "schematic.help.line_paste"));
send(player, Locales.getString(player, "schematic.help.line_list"));
send(player, Locales.getString(player, "schematic.help.line_reload"));
send(player, Locales.getString(player, "schematic.help.line_folder"));
send(player, Locales.getString(player, "schematic.help.line_docs"));
send(player, "");
SchematicMessenger.sendHelp(player);
}
public static void handleSubcommand(String sub, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
Location playerLoc = player.getLocation();
Location[] sel = getSelection(player);
switch (sub.toLowerCase()) {
case "wand" -> {
player.getInventory().addItem(getWand());
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.wand_given"));
}
case "pos1" -> setPosCommand(player, sel, playerLoc, 0);
case "pos2" -> setPosCommand(player, sel, playerLoc, 1);
case "list" -> {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.list_header"));
LoParkour.getSchematicManager().getAll().forEach((name, s) ->
send(player, Locales.getString(player, "schematic.list_entry")
.formatted(name, s.getDifficulty(), s.getFormat().name().toLowerCase())));
}
case "reload" -> {
if (!cooldowns.cooldown(sender, "schematic-reload", 2500)) {
return;
}
Config.reload(false);
LoParkour.getSchematicManager().reload();
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.reloaded"));
}
case "convert" -> handleConvert(sender, player, cooldowns);
default -> sendHelp(player);
}
SchematicDispatcher.dispatch(sub, sender, player, cooldowns);
}
public static void handleSubcommandWithName(String sub, String name, CommandSender sender, Player player) {
switch (sub.toLowerCase()) {
case "paste" -> handlePaste(name, sender, player);
case "create" -> handleCreate(name, null, sender, player);
default -> {
}
}
SchematicDispatcher.dispatchWithName(sub, name, sender, player);
}
/** {@code schematic create <name> <difficulty>} */
@ -139,10 +66,9 @@ public class SchematicCommandHandler {
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
if (cooldowns.cooldown(sender, "schematic-create", 2500)) {
SchematicHandler.create(name, difficultyRaw, sender, player);
}
handleCreate(name, difficultyRaw, sender, player);
}
/** {@code create <difficulty>} or {@code schematic create <difficulty>} */
@ -152,123 +78,12 @@ public class SchematicCommandHandler {
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
if (cooldowns.cooldown(sender, "schematic-create", 2500)) {
SchematicHandler.create(null, difficultyRaw, sender, player);
}
handleCreate(null, difficultyRaw, sender, player);
}
private static void handleCreate(
@Nullable String optionalName,
@NotNull String difficultyRaw,
CommandSender sender,
Player player
) {
Location[] sel = SELECTIONS.get(player);
if (sel == null || sel[0] == null || sel[1] == null) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.selection_incomplete"));
return;
}
double difficulty;
try {
difficulty = SchematicCreator.parseDifficulty(difficultyRaw);
} catch (NumberFormatException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
} catch (IllegalArgumentException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
}
try {
SchematicCreator.CreateResult result = SchematicCreator.create(sel[0], sel[1], optionalName, difficulty);
LoParkour.getSchematicManager().reload();
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.created")
.formatted(result.stem(), result.difficulty(), result.file().getName()));
} catch (IllegalArgumentException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
} catch (Exception e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.create_failed").formatted(e.getMessage()));
}
}
private static void handleConvert(CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
if (!cooldowns.cooldown(sender, "schematic-convert", 5000)) {
return;
}
LpschemConverter.ConvertResult result = LpschemConverter.convertAll();
if (result.converted() > 0) {
LoParkour.getSchematicManager().reload();
}
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.convert_done")
.formatted(result.converted(), result.failed()));
for (String line : result.messages()) {
if (result.messages().size() <= 12) {
send(player, "&#A0A0A0 " + line);
}
}
if (result.messages().size() > 12) {
send(player, Locales.getString(player, "schematic.convert_truncated").formatted(result.messages().size() - 12));
}
}
private static void setPosCommand(Player player, Location[] existing, Location loc, int index) {
setPos(player, loc, index);
String label = index == 0 ? "1" : "2";
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.pos_set")
.formatted(label, Locations.toString(loc, true)));
Location[] updated = getSelection(player);
if (updated[0] != null && updated[1] != null) {
ParticleUtil.box(BoundingBox.of(updated[0], updated[1]), player.getWorld(), Particle.END_ROD, player, 0.2);
}
}
private static void handlePaste(String name, CommandSender sender, Player player) {
if (!isValidSchematicName(name)) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
return;
}
ParkourSchematic schematic = LoParkour.getSchematicManager().get(name);
if (schematic == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.not_found").formatted(name));
return;
}
schematic.paste(player.getLocation(), player.getWorld());
send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.pasted").formatted(name));
}
static boolean isValidSchematicName(String name) {
if (name == null || name.isEmpty() || name.length() > 64) {
return false;
}
return name.matches("^[a-zA-Z0-9_-]+$");
return SchematicHandler.isValidSchematicName(name);
}
@NotNull
private static ItemStack buildWand() {
if (cachedWand != null) {
return cachedWand;
}
String locale = Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
if (locale == null || locale.isBlank()) {
locale = "en";
}
ItemStack item = Locales.getItem(locale, "schematic.wand").build();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.getPersistentDataContainer().set(WAND_KEY, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
cachedWand = item;
return item;
}
private static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}
}

View file

@ -0,0 +1,54 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class SchematicDispatcher {
private SchematicDispatcher() {
}
static void dispatch(String sub, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
Location playerLoc = player.getLocation();
Location[] sel = SchematicCommandHandler.getSelection(player);
switch (sub.toLowerCase()) {
case "wand" -> {
player.getInventory().addItem(SchematicCommandHandler.getWand());
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.wand_given"));
}
case "pos1" -> SchematicHandler.posCommand(player, sel, playerLoc, 0);
case "pos2" -> SchematicHandler.posCommand(player, sel, playerLoc, 1);
case "list" -> {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.list_header"));
LoParkour.getSchematicManager().getAll().forEach((name, s) ->
SchematicMessenger.send(player, Locales.getString(player, "schematic.list_entry")
.formatted(name, s.getDifficulty(), s.getFormat().name().toLowerCase())));
}
case "reload" -> {
if (!cooldowns.cooldown(sender, "schematic-reload", 2500)) {
return;
}
Config.reload(false);
LoParkour.getSchematicManager().reload();
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.reloaded"));
}
case "convert" -> SchematicHandler.convert(sender, player, cooldowns);
default -> SchematicMessenger.sendHelp(player);
}
}
static void dispatchWithName(String sub, String name, CommandSender sender, Player player) {
switch (sub.toLowerCase()) {
case "paste" -> SchematicHandler.paste(name, sender, player);
case "create" -> SchematicHandler.create(name, null, sender, player);
default -> {
}
}
}
}

View file

@ -0,0 +1,115 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.schematic.convert.LpschemConverter;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.create.SchematicCreator;
import dev.loki.loparkour.util.particle.ParticleUtil;
import dev.loki.loparkour.util.world.Locations;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.util.BoundingBox;
import java.io.IOException;
final class SchematicHandler {
private SchematicHandler() {
}
static void create(
@Nullable String optionalName,
@NotNull String difficultyRaw,
CommandSender sender,
Player player
) {
Location[] sel = SchematicCommandHandler.SELECTIONS.get(player);
if (sel == null || sel[0] == null || sel[1] == null) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.selection_incomplete"));
return;
}
double difficulty;
try {
difficulty = SchematicCreator.parseDifficulty(difficultyRaw);
} catch (IllegalArgumentException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
}
try {
SchematicCreator.CreateResult result = SchematicCreator.create(sel[0], sel[1], optionalName, difficulty);
LoParkour.getSchematicManager().reload();
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.created")
.formatted(result.stem(), result.difficulty(), result.file().getName()));
} catch (IllegalArgumentException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
} catch (IOException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.create_failed")
.formatted(e.getMessage()));
}
}
static void paste(String name, CommandSender sender, Player player) {
if (!isValidSchematicName(name)) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
return;
}
ParkourSchematic schematic = LoParkour.getSchematicManager().get(name);
if (schematic == null) {
SchematicMessenger.send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.not_found")
.formatted(name));
return;
}
schematic.paste(player.getLocation(), player.getWorld());
SchematicMessenger.send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.pasted")
.formatted(name));
}
static void convert(CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
if (!cooldowns.cooldown(sender, "schematic-convert", 5000)) {
return;
}
LpschemConverter.ConvertResult result = LpschemConverter.convertAll();
if (result.converted() > 0) {
LoParkour.getSchematicManager().reload();
}
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.convert_done")
.formatted(result.converted(), result.failed()));
for (String line : result.messages()) {
if (result.messages().size() <= 12) {
SchematicMessenger.send(player, "&#A0A0A0 " + line);
}
}
if (result.messages().size() > 12) {
SchematicMessenger.send(player, Locales.getString(player, "schematic.convert_truncated")
.formatted(result.messages().size() - 12));
}
}
static void posCommand(Player player, Location[] existing, Location loc, int index) {
SchematicCommandHandler.setPos(player, loc, index);
String label = index == 0 ? "1" : "2";
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.pos_set")
.formatted(label, Locations.toString(loc, true)));
Location[] updated = SchematicCommandHandler.getSelection(player);
if (updated[0] != null && updated[1] != null) {
ParticleUtil.box(BoundingBox.of(updated[0], updated[1]), player.getWorld(), Particle.END_ROD, player, 0.2);
}
}
static boolean isValidSchematicName(String name) {
if (name == null || name.isEmpty() || name.length() > 64) {
return false;
}
return name.matches("^[a-zA-Z0-9_-]+$");
}
}

View file

@ -0,0 +1,35 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
final class SchematicMessenger {
private SchematicMessenger() {
}
static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
static void sendHelp(@NotNull Player player) {
send(player, "");
send(player, Locales.getString(player, "schematic.help.header"));
send(player, "");
send(player, Locales.getString(player, "schematic.help.line_wand"));
send(player, Locales.getString(player, "schematic.help.line_pos1"));
send(player, Locales.getString(player, "schematic.help.line_pos2"));
send(player, Locales.getString(player, "schematic.help.line_create"));
send(player, Locales.getString(player, "schematic.help.line_create_named"));
send(player, Locales.getString(player, "schematic.help.line_convert"));
send(player, Locales.getString(player, "schematic.help.line_paste"));
send(player, Locales.getString(player, "schematic.help.line_list"));
send(player, Locales.getString(player, "schematic.help.line_reload"));
send(player, Locales.getString(player, "schematic.help.line_folder"));
send(player, Locales.getString(player, "schematic.help.line_docs"));
send(player, "");
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
final class SchematicWand {
private static ItemStack cachedWand;
private SchematicWand() {
}
@NotNull
static ItemStack getWand() {
return build().clone();
}
static boolean isWand(@Nullable ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return false;
}
ItemMeta meta = item.getItemMeta();
return meta != null
&& meta.getPersistentDataContainer().has(SchematicCommandHandler.WAND_KEY, PersistentDataType.BYTE);
}
static void clearCache() {
cachedWand = null;
}
@NotNull
private static ItemStack build() {
if (cachedWand != null) {
return cachedWand;
}
String locale = Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
if (locale == null || locale.isBlank()) {
locale = "en";
}
ItemStack item = Locales.getItem(locale, "schematic.wand").build();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.getPersistentDataContainer().set(SchematicCommandHandler.WAND_KEY, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
cachedWand = item;
return item;
}
}

View file

@ -0,0 +1,27 @@
package dev.loki.loparkour.command.schematic;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
final class SelectionStore {
private SelectionStore() {
}
@NotNull
static Location[] get(@NotNull Player player) {
Location[] sel = SchematicCommandHandler.SELECTIONS.get(player);
if (sel == null) {
sel = new Location[]{null, null};
SchematicCommandHandler.SELECTIONS.put(player, sel);
}
return sel;
}
static void set(@NotNull Player player, @NotNull Location loc, int index) {
Location[] updated = get(player).clone();
updated[index] = loc.clone();
SchematicCommandHandler.SELECTIONS.put(player, updated);
}
}

View file

@ -0,0 +1,14 @@
package dev.loki.loparkour.command.util;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
public final class CommandUtil {
private CommandUtil() {
}
public static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -35,14 +35,13 @@ public class ConfigLoader {
}
public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List<String> ignoredSections) {
try {
var plugin = LoParkour.getPlugin();
var resourceStream = plugin.getResource(fileName);
var plugin = LoParkour.getPlugin();
try (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,
plugin.getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to update config file: " + fileName, ex);
}
}

View file

@ -0,0 +1,108 @@
package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Reads YAML as raw lines and extracts the full path of each scalar value
* (e.g. "sql.enabled") so that user values can be matched against the template.
*/
final class ConfigMergeParser {
@NotNull
List<String> readLines(@NotNull InputStream is) throws IOException {
try (var r = new BufferedReader(new InputStreamReader(is))) {
return r.lines().toList();
}
}
@NotNull
List<String> readLines(@NotNull File file) throws IOException {
return Files.readAllLines(file.toPath());
}
@NotNull
Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
Map<String, String> values = new LinkedHashMap<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : lines) {
LineTokens tokens = parseLine(line);
if (tokens == null) {
continue;
}
prevIndent = popToLevel(stack, tokens.indent(), prevIndent);
String fullPath = resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
stack.push(tokens.leaf());
} else {
values.put(fullPath, tokens.after());
}
prevIndent = tokens.indent();
}
return values;
}
@Nullable
static LineTokens parseLine(@NotNull String line) {
if (isSkippable(line)) {
return null;
}
String trimmed = line.trim();
int colon = trimmed.indexOf(':');
if (colon == -1) {
return null;
}
String leaf = trimmed.substring(0, colon).trim();
String after = trimmed.substring(colon + 1).trim();
return new LineTokens(leaf, after, indentOf(line), isSection(after));
}
private static boolean isSkippable(@NotNull String line) {
String trimmed = line.trim();
return trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-");
}
private static boolean isSection(@NotNull String after) {
return after.isEmpty() || after.startsWith("#");
}
static int popToLevel(@NotNull Deque<String> stack, int indent, int prevIndent) {
while (prevIndent >= 0 && indent <= prevIndent) {
if (!stack.isEmpty()) {
stack.pop();
}
prevIndent -= 2;
}
return prevIndent;
}
@NotNull
static String resolvePath(@NotNull Deque<String> stack, @NotNull String leaf) {
return stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
}
static int indentOf(@NotNull String line) {
int i = 0;
while (i < line.length() && line.charAt(i) == ' ') {
i++;
}
return i;
}
record LineTokens(String leaf, String after, int indent, boolean isSection) {
}
}

View file

@ -0,0 +1,96 @@
package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Builds the merged file by walking the template and substituting any scalar
* value that the user has already customized, preserving comments and sections.
*/
final class ConfigMergeProcessor {
private final ConfigMergeParser parser = new ConfigMergeParser();
@NotNull
List<String> merge(
@NotNull InputStream templateStream,
@NotNull File existingFile,
@Nullable List<String> ignoredKeys) throws IOException {
List<String> templateLines = parser.readLines(templateStream);
List<String> existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of();
Set<String> ignored = normalizeIgnored(ignoredKeys);
Map<String, String> existingValues = parser.extractFullPathValues(existingLines);
List<String> result = new ArrayList<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : templateLines) {
ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(line);
if (tokens == null) {
result.add(line);
continue;
}
prevIndent = ConfigMergeParser.popToLevel(stack, tokens.indent(), prevIndent);
String fullPath = ConfigMergeParser.resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
stack.push(tokens.leaf());
result.add(line);
} else if (isIgnored(fullPath, ignored)) {
result.add(line);
} else {
result.add(applyExistingValue(line, fullPath, existingValues));
}
prevIndent = tokens.indent();
}
return result;
}
@NotNull
private static String applyExistingValue(
@NotNull String line,
@NotNull String fullPath,
@NotNull Map<String, String> existingValues) {
String existing = existingValues.get(fullPath);
if (existing == null) {
return line;
}
return line.substring(0, line.indexOf(':') + 1) + " " + existing;
}
@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 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;
}
}

View file

@ -3,21 +3,12 @@ package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
/**
@ -52,7 +43,7 @@ public class ConfigUpdater {
File backup = createBackup(configFile);
try {
List<String> merged = merge(templateStream, configFile, ignoredKeys);
List<String> merged = new ConfigMergeProcessor().merge(templateStream, configFile, ignoredKeys);
Files.write(configFile.toPath(), merged);
logger.info("Configuration file synced: " + configFile.getName());
} catch (Exception e) {
@ -64,120 +55,6 @@ public class ConfigUpdater {
}
}
// ── merge ─────────────────────────────────────────────────────────────────
@NotNull
private List<String> merge(
@NotNull InputStream templateStream,
@NotNull File existingFile,
@Nullable List<String> ignoredKeys) throws IOException {
List<String> templateLines = readLines(templateStream);
List<String> existingLines = existingFile.exists() ? readLines(existingFile) : List.of();
Set<String> ignored = normalizeIgnored(ignoredKeys);
Map<String, String> existingValues = extractFullPathValues(existingLines);
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;
}
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;
}
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
boolean section = after.isEmpty() || after.startsWith("#");
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;
}
}
return result;
}
@NotNull
private Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
Map<String, String> values = new LinkedHashMap<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
continue;
}
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;
}
return values;
}
// ── helpers ───────────────────────────────────────────────────────────────
private static int indentOf(@NotNull String line) {
int i = 0;
while (i < line.length() && line.charAt(i) == ' ') {
i++;
}
return i;
}
@Nullable
private File createBackup(@NotNull File file) {
if (!file.exists()) {
@ -192,38 +69,4 @@ public class ConfigUpdater {
return null;
}
}
@NotNull
private static List<String> readLines(@NotNull InputStream is) throws IOException {
try (var r = new BufferedReader(new InputStreamReader(is))) {
return r.lines().toList();
}
}
@NotNull
private static List<String> readLines(@NotNull File file) throws IOException {
return Files.readAllLines(file.toPath());
}
@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 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;
}
}
}

View file

@ -1,40 +1,34 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.calculation.JumpCalculator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.util.misc.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* Refactored BlockPlacer using composition pattern.
* Coordinates block selection, jump calculation, and placement.
* Facade coordinating block selection, jump calculation, and placement.
*/
public class BlockPlacer {
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
private final JumpCalculator jumpCalculator;
private final BlockTargetSelector targetSelector;
private final BlockWorldPlacer worldPlacer;
private final SchematicBlockPlacer schematicPlacer;
public BlockPlacer(@NotNull ParkourGenerator generator) {
this.generator = generator;
this.blockSelector = new BlockSelector(generator);
this.jumpCalculator = new JumpCalculator(generator, blockSelector);
this.targetSelector = new BlockTargetSelector(generator, blockSelector, new JumpCalculator(generator, blockSelector));
this.worldPlacer = new BlockWorldPlacer(generator);
this.schematicPlacer = new SchematicBlockPlacer(generator, targetSelector);
}
/**
* Generate a single block.
*/
@ -51,85 +45,60 @@ public class BlockPlacer {
}
}
private boolean isTallMaterial(@NotNull Material mat) {
return blockSelector.isFenceMaterial(mat) || blockSelector.isTrapdoorMaterial(mat) || mat == Material.GLASS_PANE;
}
/**
* Select blocks with a forced height override for tall materials (fences, trapdoors).
*/
@NotNull
private List<Block> selectBlocks(int forcedHeight) {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks;
}
Block current = generator.getLatest();
int distance = Probs.random(generator.state.distanceChances);
int height = forcedHeight != Integer.MIN_VALUE ? forcedHeight : Probs.random(generator.state.heightChances);
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
/**
* 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);
worldPlacer.place(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();
}
worldPlacer.deleteSchematic();
}
private void generateSingleBlock() {
ParkourGenerator.BlockGenerationType jumpType = selectGenerationType();
switch (jumpType) {
case DEFAULT -> placeNormalBlock();
case SPECIAL -> placeSpecialBlock();
case SCHEMATIC -> {
if (!tryGenerateSchematic()) {
placeNormalBlock(); // Fallback to normal block
if (!schematicPlacer.placeSchematic()) {
placeNormalBlock();
}
}
default -> {}
default -> {
}
}
}
@NotNull
private ParkourGenerator.BlockGenerationType selectGenerationType() {
return Probs.random(generator.state.defaultChances);
}
private void placeNormalBlock() {
BlockData blockData = blockSelector.selectBlockData();
if (blockData == null) {
return;
}
Material mat = blockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
boolean tall = targetSelector.isTallMaterial(blockData.getMaterial());
List<Block> blocks = tall ? targetSelector.selectBlocks(-1) : targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return;
}
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, blockData);
worldPlacer.place(selectedBlock, blockData);
generator.state.history.add(selectedBlock);
}
@ -140,88 +109,14 @@ public class BlockPlacer {
return;
}
Material mat = specialBlockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
boolean tall = targetSelector.isTallMaterial(specialBlockData.getMaterial());
List<Block> blocks = tall ? targetSelector.selectBlocks(-1) : targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return;
}
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, specialBlockData);
worldPlacer.place(selectedBlock, specialBlockData);
generator.state.history.add(selectedBlock);
}
/** Prefer no neighbor physics to avoid fragile vanilla multi-block checks in empty worlds. */
private void placeBlockData(@NotNull Block block, @NotNull BlockData data) {
block.setBlockData(data, false);
// Play effects for all players
Location loc = block.getLocation().add(0.5, 0.5, 0.5);
for (ParkourPlayer player : generator.getPlayers()) {
// Spawn particles
player.player.spawnParticle(Particle.BLOCK_CRACK, loc, 10, 0.3, 0.3, 0.3, 0.1, data);
// Play sound
player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, 0.5f, 1.0f);
}
}
@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() {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return false;
}
double maxDifficulty = generator.getPlayers().stream()
.mapToDouble(p -> p.schematicDifficulty)
.min()
.orElse(1.0);
ParkourSchematic schematic = manager.pick(maxDifficulty);
if (schematic == null) {
return false;
}
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) {
return false;
}
Location pasteLocation = blocks.get(0).getLocation();
List<Block> schematicBlocks = schematic.paste(pasteLocation, pasteLocation.getWorld());
if (schematicBlocks.isEmpty()) {
return false;
}
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
for (ParkourPlayer player : generator.getPlayers()) {
new ParkourSchematicGenerateEvent(schematic, generator, player).call();
}
return true;
}
}
}

View file

@ -0,0 +1,52 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.calculation.JumpCalculator;
import dev.loki.loparkour.util.misc.Probs;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* Computes the target block position for generated parkour blocks based on jump parameters.
*/
class BlockTargetSelector {
private static final int NO_FORCED_HEIGHT = Integer.MIN_VALUE;
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
private final JumpCalculator jumpCalculator;
BlockTargetSelector(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector, @NotNull JumpCalculator jumpCalculator) {
this.generator = generator;
this.blockSelector = blockSelector;
this.jumpCalculator = jumpCalculator;
}
boolean isTallMaterial(@NotNull Material mat) {
return blockSelector.isFenceMaterial(mat) || blockSelector.isTrapdoorMaterial(mat) || mat == Material.GLASS_PANE;
}
@NotNull
List<Block> selectBlocks() {
return selectBlocks(NO_FORCED_HEIGHT);
}
@NotNull
List<Block> selectBlocks(int forcedHeight) {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks;
}
Block current = generator.getLatest();
int distance = Probs.random(generator.state.distanceChances);
int height = forcedHeight != NO_FORCED_HEIGHT ? forcedHeight : Probs.random(generator.state.heightChances);
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
/**
* Applies block data to the world and plays placement effects for all players.
*/
class BlockWorldPlacer {
private final ParkourGenerator generator;
BlockWorldPlacer(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
void place(@NotNull Block block, @NotNull BlockData data) {
block.setBlockData(data, false);
Location loc = block.getLocation().add(0.5, 0.5, 0.5);
for (ParkourPlayer player : generator.getPlayers()) {
player.player.spawnParticle(Particle.BLOCK_CRACK, loc, 10, 0.3, 0.3, 0.3, 0.1, data);
player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, 0.5f, 1.0f);
}
}
void deleteSchematic() {
if (generator.state.schematicBlocks != null) {
generator.state.schematicBlocks.forEach(block -> block.setType(Material.AIR));
generator.state.schematicBlocks.clear();
}
}
}

View file

@ -0,0 +1,63 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.core.SchematicManager;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Pastes schematic parkour sections into the world and fires the related event.
*/
class SchematicBlockPlacer {
private final ParkourGenerator generator;
private final BlockTargetSelector targetSelector;
SchematicBlockPlacer(@NotNull ParkourGenerator generator, @NotNull BlockTargetSelector targetSelector) {
this.generator = generator;
this.targetSelector = targetSelector;
}
boolean placeSchematic() {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return false;
}
double maxDifficulty = generator.getPlayers().stream()
.mapToDouble(p -> p.schematicDifficulty)
.min()
.orElse(1.0);
ParkourSchematic schematic = manager.pick(maxDifficulty);
if (schematic == null) {
return false;
}
List<Block> blocks = targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return false;
}
Location pasteLocation = blocks.get(0).getLocation();
List<Block> schematicBlocks = schematic.paste(pasteLocation, pasteLocation.getWorld());
if (schematicBlocks.isEmpty()) {
return false;
}
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
for (ParkourPlayer player : generator.getPlayers()) {
new ParkourSchematicGenerateEvent(schematic, generator, player).call();
}
return true;
}
}

View file

@ -1,26 +1,13 @@
package dev.loki.loparkour.hook.papi;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.spectator.ParkourSpectator;
import dev.loki.loparkour.player.core.ParkourUser;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PAPIHook extends PlaceholderExpansion {
private static final Pattern INFINITE_REGEX = Pattern.compile("(.+)_(\\d+)");
private final PlaceholderDispatcher dispatcher = new PlaceholderDispatcher();
@Override
public @NotNull String getIdentifier() {
@ -48,173 +35,7 @@ public class PAPIHook extends PlaceholderExpansion {
}
@Override
@SuppressWarnings({"checkstyle:MethodLength", "checkstyle:CyclomaticComplexity"})
public String onPlaceholderRequest(Player player, @NotNull String params) {
// placeholders that don't require a player
switch (params) {
case "version", "ver" -> {
return LoParkour.getPlugin().getDescription().getVersion();
}
case "leader", "record_player" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? score.name() : "?";
}
case "leader_score", "record_score", "record" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? Integer.toString(score.score()) : "?";
}
default -> {}
}
if (params.contains("player_rank_")) {
return getInfiniteScore(params.replace("player_rank_", ""), Score::name);
} else if (params.contains("score_rank_")) {
return getInfiniteScore(params.replace("score_rank_", ""), Score::score);
} else if (params.contains("time_rank_")) {
return getInfiniteScore(params.replace("time_rank_", ""), Score::time);
} else if (params.contains("difficulty_rank_")) {
return getInfiniteScore(params.replace("difficulty_rank_", ""), Score::difficulty);
} else if (params.contains("difficulty_string_rank_")) {
return getInfiniteScore(params.replace("difficulty_string_rank_", ""),
score -> {
String diff = score.difficulty();
if ("?".equals(diff)) {
return parseDifficulty(2.0);
}
try {
return parseDifficulty(Double.parseDouble(diff));
} catch (NumberFormatException e) {
return parseDifficulty(2.0);
}
});
}
// placeholders that require player
if (player == null) {
return "player doesn't exist";
}
switch (params) {
case "rank" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().getRank(player.getUniqueId()));
}
case "highscore", "high_score" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).score());
}
case "high_score_time" -> {
return Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).time();
}
default -> {}
}
ParkourUser user = ParkourUser.getUser(player);
ParkourPlayer pp = null;
if (user instanceof ParkourPlayer) {
pp = (ParkourPlayer) user;
} else if (user instanceof ParkourSpectator) {
pp = ((ParkourSpectator) user).closest;
}
if (pp != null && pp.session.generator != null) {
ParkourGenerator generator = pp.session.generator;
switch (params) {
case "score", "current_score" -> {
return Integer.toString(generator.state.score);
}
case "time", "current_time" -> {
return generator.getFormattedTime();
}
case "blocklead", "lead" -> {
return Integer.toString(pp.blockLead);
}
case "style" -> {
return pp.style;
}
case "time_pref", "time_preference" -> {
return Integer.toString(pp.selectedTime);
}
case "scoreboard" -> {
return pp.showScoreboard.toString();
}
case "difficulty" -> {
return Double.toString(pp.schematicDifficulty);
}
case "difficulty_string" -> {
return parseDifficulty(pp.schematicDifficulty);
}
default -> {
if (params.contains("score_until_")) {
String replaced = params.replace("score_until_", "");
try {
int interval = Integer.parseInt(replaced);
if (interval > 0) {
return Integer.toString(interval - (generator.state.score % interval));
} else {
return "0";
}
} catch (NumberFormatException e) {
return "0";
}
}
}
}
}
return null;
return dispatcher.dispatch(player, params);
}
private String parseDifficulty(double difficulty) {
if (difficulty <= 0.25) {
return "easy";
} else if (difficulty <= 0.5) {
return "medium";
} else if (difficulty <= 0.75) {
return "hard";
} else if (difficulty <= 1) {
return "very hard";
}
return "?";
}
private String getInfiniteScore(String rankData, Function<Score, ?> f) {
int rank;
Leaderboard leaderboard = null;
Matcher matcher = INFINITE_REGEX.matcher(rankData);
try {
// use mode-specific format
// x_mode_rank
if (matcher.matches()) {
String name = matcher.group(1);
rank = Integer.parseInt(matcher.group(2));
Mode mode = Registry.getMode(name);
if (mode != null) {
leaderboard = mode.getLeaderboard();
}
// use generic format
// x_rank
} else {
rank = Integer.parseInt(rankData);
}
} catch (NumberFormatException e) {
return "?";
}
if (leaderboard == null) {
leaderboard = Modes.DEFAULT.getLeaderboard();
}
if (rank > 0) {
Score score = leaderboard.getScoreAtRank(rank);
if (score == null) {
return "?";
} else {
return String.valueOf(f.apply(score));
}
} else {
return "?";
}
}
}
}

View file

@ -0,0 +1,23 @@
package dev.loki.loparkour.hook.papi;
import dev.loki.loparkour.hook.papi.resolver.GlobalPlaceholderResolver;
import dev.loki.loparkour.hook.papi.resolver.PlayerPlaceholderResolver;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
class PlaceholderDispatcher {
private final GlobalPlaceholderResolver globalResolver = new GlobalPlaceholderResolver();
private final PlayerPlaceholderResolver playerResolver = new PlayerPlaceholderResolver();
String dispatch(Player player, @NotNull String params) {
String resolved = globalResolver.resolve(params);
if (resolved != null) {
return resolved;
}
if (player == null) {
return "player doesn't exist";
}
return playerResolver.resolve(player, params);
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Modes;
public class GlobalPlaceholderResolver {
private final ScoreRankResolver rankResolver = new ScoreRankResolver();
private final PlaceholderFormatters formatters = new PlaceholderFormatters();
public String resolve(String params) {
String value = resolveStatic(params);
if (value != null) {
return value;
}
return resolveRanked(params);
}
private String resolveStatic(String params) {
switch (params) {
case "version", "ver" -> {
return LoParkour.getPlugin().getDescription().getVersion();
}
case "leader", "record_player" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? score.name() : "?";
}
case "leader_score", "record_score", "record" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? Integer.toString(score.score()) : "?";
}
default -> {
return null;
}
}
}
private String resolveRanked(String params) {
if (params.contains("player_rank_")) {
return rankResolver.resolve(params.replace("player_rank_", ""), Score::name);
}
if (params.contains("score_rank_")) {
return rankResolver.resolve(params.replace("score_rank_", ""), Score::score);
}
if (params.contains("time_rank_")) {
return rankResolver.resolve(params.replace("time_rank_", ""), Score::time);
}
if (params.contains("difficulty_rank_")) {
return rankResolver.resolve(params.replace("difficulty_rank_", ""), Score::difficulty);
}
if (params.contains("difficulty_string_rank_")) {
return rankResolver.resolve(params.replace("difficulty_string_rank_", ""),
formatters::difficultyString);
}
return null;
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.leaderboard.model.Score;
class PlaceholderFormatters {
String parseDifficulty(double difficulty) {
if (difficulty <= 0.25) {
return "easy";
} else if (difficulty <= 0.5) {
return "medium";
} else if (difficulty <= 0.75) {
return "hard";
} else if (difficulty <= 1) {
return "very hard";
}
return "?";
}
String difficultyString(Score score) {
String diff = score.difficulty();
if ("?".equals(diff)) {
return parseDifficulty(2.0);
}
try {
return parseDifficulty(Double.parseDouble(diff));
} catch (NumberFormatException e) {
return parseDifficulty(2.0);
}
}
}

View file

@ -0,0 +1,104 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.player.spectator.ParkourSpectator;
import org.bukkit.entity.Player;
public class PlayerPlaceholderResolver {
private final PlaceholderFormatters formatters = new PlaceholderFormatters();
public String resolve(Player player, String params) {
String value = resolveLeaderboard(player, params);
if (value != null) {
return value;
}
return resolveSession(player, params);
}
private String resolveLeaderboard(Player player, String params) {
switch (params) {
case "rank" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().getRank(player.getUniqueId()));
}
case "highscore", "high_score" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).score());
}
case "high_score_time" -> {
return Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).time();
}
default -> {
return null;
}
}
}
private String resolveSession(Player player, String params) {
ParkourUser user = ParkourUser.getUser(player);
ParkourPlayer pp = extractParkourPlayer(user);
if (pp == null || pp.session.generator == null) {
return null;
}
return resolveGenerator(pp, params);
}
private ParkourPlayer extractParkourPlayer(ParkourUser user) {
if (user instanceof ParkourPlayer) {
return (ParkourPlayer) user;
}
if (user instanceof ParkourSpectator) {
return ((ParkourSpectator) user).closest;
}
return null;
}
private String resolveGenerator(ParkourPlayer pp, String params) {
switch (params) {
case "score", "current_score" -> {
return Integer.toString(pp.session.generator.state.score);
}
case "time", "current_time" -> {
return pp.session.generator.getFormattedTime();
}
case "blocklead", "lead" -> {
return Integer.toString(pp.blockLead);
}
case "style" -> {
return pp.style;
}
case "time_pref", "time_preference" -> {
return Integer.toString(pp.selectedTime);
}
case "scoreboard" -> {
return pp.showScoreboard.toString();
}
case "difficulty" -> {
return Double.toString(pp.schematicDifficulty);
}
case "difficulty_string" -> {
return formatters.parseDifficulty(pp.schematicDifficulty);
}
default -> {
return resolveScoreUntil(pp, params);
}
}
}
private String resolveScoreUntil(ParkourPlayer pp, String params) {
if (!params.contains("score_until_")) {
return null;
}
String replaced = params.replace("score_until_", "");
try {
int interval = Integer.parseInt(replaced);
if (interval > 0) {
return Integer.toString(interval - (pp.session.generator.state.score % interval));
}
return "0";
} catch (NumberFormatException e) {
return "0";
}
}
}

View file

@ -0,0 +1,59 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class ScoreRankResolver {
private static final Pattern INFINITE_REGEX = Pattern.compile("(.+)_(\\d+)");
String resolve(String rankData, Function<Score, ?> valueFunction) {
Matcher matcher = INFINITE_REGEX.matcher(rankData);
if (matcher.matches()) {
return resolveModeRank(matcher, valueFunction);
}
return resolveGenericRank(rankData, valueFunction);
}
private String resolveModeRank(Matcher matcher, Function<Score, ?> valueFunction) {
try {
int rank = Integer.parseInt(matcher.group(2));
String name = matcher.group(1);
Leaderboard leaderboard = Modes.DEFAULT.getLeaderboard();
Mode mode = Registry.getMode(name);
if (mode != null) {
leaderboard = mode.getLeaderboard();
}
return resolveScore(leaderboard, rank, valueFunction);
} catch (NumberFormatException e) {
return "?";
}
}
private String resolveGenericRank(String rankData, Function<Score, ?> valueFunction) {
try {
int rank = Integer.parseInt(rankData);
return resolveScore(Modes.DEFAULT.getLeaderboard(), rank, valueFunction);
} catch (NumberFormatException e) {
return "?";
}
}
private String resolveScore(Leaderboard leaderboard, int rank, Function<Score, ?> valueFunction) {
if (rank <= 0) {
return "?";
}
Score score = leaderboard.getScoreAtRank(rank);
if (score == null) {
return "?";
}
return String.valueOf(valueFunction.apply(score));
}
}

View file

@ -1,20 +1,19 @@
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.persistence.LeaderboardStorage;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.leaderboard.persistence.LeaderboardStorage;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Facade for leaderboard: CRUD operations with automatic sorting.
* Delegates to LeaderboardStorage and LeaderboardSorter.
* Delegates to LeaderboardStorage, LeaderboardSorter and LeaderboardRecords.
*/
public class Leaderboard {
@ -35,11 +34,13 @@ public class Leaderboard {
private final LeaderboardStorage storage;
private final LeaderboardSorter sorter;
private final LeaderboardRecords records;
public Leaderboard(@NotNull String mode, LeaderboardSorter.Sort sort) {
this.mode = mode.toLowerCase();
this.sort = sort;
this.sorter = new LeaderboardSorter(sort);
this.records = new LeaderboardRecords(scores, sorter);
this.storage = new LeaderboardStorage(mode, scores, sorter);
storage.initAndSchedule();
@ -81,27 +82,7 @@ public class Leaderboard {
*/
@Nullable
public Score put(@NotNull UUID uuid, @NotNull Score score) {
Score previous;
synchronized (scores) {
previous = scores.get(uuid);
if (previous != null && !isBetterThan(score, previous)) {
return previous; // keep the better score
}
scores.put(uuid, score);
}
sorter.sortInPlace(scores);
return previous;
}
/** Returns true if {@code candidate} is strictly better than {@code existing}. */
private static boolean isBetterThan(@NotNull Score candidate, @NotNull Score existing) {
if (candidate.score() != existing.score()) {
return candidate.score() > existing.score();
}
// same score — faster time wins
return candidate.getTimeMillis() < existing.getTimeMillis();
return records.put(uuid, score);
}
/**
@ -112,18 +93,14 @@ public class Leaderboard {
*/
@Nullable
public Score remove(@NotNull UUID uuid) {
return scores.remove(uuid);
return records.remove(uuid);
}
/**
* Resets all registered scores for this mode
*/
public void resetAll() {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
keys.forEach(this::remove);
records.resetAll();
}
/**
@ -132,7 +109,7 @@ public class Leaderboard {
*/
@NotNull
public Score get(@NotNull UUID uuid) {
return scores.getOrDefault(uuid, new Score("?", "?", "?", 0));
return records.get(uuid);
}
/**
@ -140,11 +117,7 @@ public class Leaderboard {
* @return The rank. Starts from 1. Returns 0 if no ranking is found
*/
public int getRank(@NotNull UUID uuid) {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
return keys.indexOf(uuid) + 1;
return records.getRank(uuid);
}
/**
@ -156,14 +129,7 @@ public class Leaderboard {
*/
@Nullable
public Score getScoreAtRank(int rank) {
List<Score> values;
synchronized (scores) {
if (scores.size() < rank) {
return null;
}
values = new ArrayList<>(scores.values());
}
return values.get(rank - 1);
return records.getScoreAtRank(rank);
}
// ── Sorting ───────────────────────────────────────────────────────────────

View file

@ -0,0 +1,120 @@
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Manages individual score entries for a leaderboard: add, update, remove and
* read operations. Keeps the backing map sorted after each mutation.
*/
class LeaderboardRecords {
private final Map<UUID, Score> scores;
private final LeaderboardSorter sorter;
LeaderboardRecords(@NotNull Map<UUID, Score> scores, @NotNull LeaderboardSorter sorter) {
this.scores = scores;
this.sorter = sorter;
}
/**
* Registers a new score — only replaces the old one if it's strictly better
* (higher score, or same score with faster time).
*
* @param uuid The player's uuid
* @param score The {@link Score} instance associated with a player's run
* @return the previous best score, if there was one
*/
@Nullable
Score put(@NotNull UUID uuid, @NotNull Score score) {
Score previous;
synchronized (scores) {
previous = scores.get(uuid);
if (previous != null && !isBetterThan(score, previous)) {
return previous;
}
scores.put(uuid, score);
}
sorter.sortInPlace(scores);
return previous;
}
/** Returns true if {@code candidate} is strictly better than {@code existing}. */
private static boolean isBetterThan(@NotNull Score candidate, @NotNull Score existing) {
if (candidate.score() != existing.score()) {
return candidate.score() > existing.score();
}
return candidate.getTimeMillis() < existing.getTimeMillis();
}
/**
* Resets the score of a player by deleting it from the internal map
*
* @param uuid The UUID
* @return the previous value if one was found
*/
@Nullable
Score remove(@NotNull UUID uuid) {
return scores.remove(uuid);
}
/**
* Resets all registered scores for this mode
*/
void resetAll() {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
keys.forEach(this::remove);
}
/**
* @param uuid The {@link UUID} to get
* @return The {@link Score} associated with the player. If null, returns a {@link Score} instance with "?"
*/
@NotNull
Score get(@NotNull UUID uuid) {
return scores.getOrDefault(uuid, new Score("?", "?", "?", 0));
}
/**
* @param uuid The uuid
* @return The rank. Starts from 1. Returns 0 if no ranking is found
*/
int getRank(@NotNull UUID uuid) {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
return keys.indexOf(uuid) + 1;
}
/**
* Gets the score at a specified rank.
* Ranks start at 1.
*
* @param rank The rank
* @return the {@link Score} instance, null if one isn't found
*/
@Nullable
Score getScoreAtRank(int rank) {
List<Score> values;
synchronized (scores) {
if (scores.size() < rank) {
return null;
}
values = new ArrayList<>(scores.values());
}
return values.get(rank - 1);
}
}

View file

@ -1,13 +1,13 @@
package dev.loki.loparkour.mode.base;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.mode.impl.CoopMode;
import dev.loki.loparkour.mode.impl.DefaultMode;
import dev.loki.loparkour.mode.impl.GravityShiftMode;
import dev.loki.loparkour.mode.impl.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.RaceMode;
import dev.loki.loparkour.mode.impl.SpectatorMode;
import dev.loki.loparkour.mode.impl.SpeedrunMode;
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.multi.CoopMode;
import dev.loki.loparkour.mode.impl.multi.RaceMode;
import dev.loki.loparkour.mode.impl.multi.SpectatorMode;
import dev.loki.loparkour.mode.impl.solo.DefaultMode;
import dev.loki.loparkour.mode.impl.solo.GravityShiftMode;
import dev.loki.loparkour.mode.impl.solo.SpeedrunMode;
public final class Modes {

View file

@ -1,222 +0,0 @@
package dev.loki.loparkour.mode.impl;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Item;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
import java.util.Set;
public class InvisibleBarrierMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "invisible-barrier";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.invisible-barrier");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!ModeMessages.checkJoiningEnabled(player)) {
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof BarrierGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new BarrierGenerator(session), null, null, player);
}
static class BarrierGenerator extends ParkourGenerator {
private static final double RENDER_DISTANCE = 24.0;
private static final double PARTICLE_STEP = 0.1;
private final Set<Location> activeBarriers = new HashSet<>();
private final Random random = new Random();
private final Map<Location, Particle.DustOptions> blockColors = new HashMap<>();
private int particleTick = 0;
BarrierGenerator(@NotNull Session session) {
super(session);
}
@Override
public void generateFirst(@NotNull Location spawn, @NotNull Location block) {
super.generateFirst(spawn, block);
convertLastBlockToBarrier();
}
@Override
public void generate(int amount) {
super.generate(amount);
int start = Math.max(0, state.history.size() - amount);
for (int i = start; i < state.history.size(); i++) {
convertBlockToBarrier(state.history.get(i));
}
}
@Override
public void tick() {
super.tick();
particleTick++;
if (particleTick % 4 == 0) {
renderOutlines();
}
}
@Override
public void reset(boolean regenerate) {
activeBarriers.clear();
blockColors.clear();
particleTick = 0;
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.INVISIBLE_BARRIER;
}
private void convertLastBlockToBarrier() {
if (state.history.isEmpty()) {
return;
}
convertBlockToBarrier(state.history.get(state.history.size() - 1));
}
private void convertBlockToBarrier(@NotNull Block block) {
Location loc = block.getLocation();
if (block.getType() == Material.BARRIER) {
return;
}
block.setType(Material.BARRIER, false);
activeBarriers.add(loc);
Color color = Color.fromRGB(random.nextInt(256), random.nextInt(256), random.nextInt(256));
blockColors.put(loc, new Particle.DustOptions(color, 1.0f));
}
private void renderOutlines() {
if (activeBarriers.isEmpty()) {
return;
}
// Self-clean invalid entries
Iterator<Map.Entry<Location, Particle.DustOptions>> it = blockColors.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Location, Particle.DustOptions> entry = it.next();
if (entry.getKey().getBlock().getType() != Material.BARRIER) {
it.remove();
activeBarriers.remove(entry.getKey());
}
}
for (ParkourPlayer pp : getPlayers()) {
Player player = pp.player;
Location playerLoc = player.getLocation();
for (Location loc : activeBarriers) {
if (loc.getWorld() != playerLoc.getWorld()) {
continue;
}
if (loc.distanceSquared(playerLoc) > RENDER_DISTANCE * RENDER_DISTANCE) {
continue;
}
Particle.DustOptions options = blockColors.get(loc);
if (options == null) {
continue;
}
drawBlockOutline(player, loc, options);
}
}
}
private record Point(double x, double y, double z) {}
private void drawBlockOutline(@NotNull Player player, @NotNull Location loc, @NotNull Particle.DustOptions options) {
double x = loc.getX();
double y = loc.getY();
double z = loc.getZ();
Point p = new Point(x, y, z);
Point q = new Point(x + 1, y, z);
Point r = new Point(x + 1, y, z + 1);
Point s = new Point(x, y, z + 1);
// Bottom edges (y offset = 0)
drawEdge(player, p, q, options);
drawEdge(player, q, r, options);
drawEdge(player, r, s, options);
drawEdge(player, s, p, options);
// Top edges (y offset = 1)
Point pt = new Point(x, y + 1, z);
Point qt = new Point(x + 1, y + 1, z);
Point rt = new Point(x + 1, y + 1, z + 1);
Point st = new Point(x, y + 1, z + 1);
drawEdge(player, pt, qt, options);
drawEdge(player, qt, rt, options);
drawEdge(player, rt, st, options);
drawEdge(player, st, pt, options);
// Vertical edges
drawEdge(player, p, pt, options);
drawEdge(player, q, qt, options);
drawEdge(player, r, rt, options);
drawEdge(player, s, st, options);
}
private void drawEdge(@NotNull Player player, @NotNull Point start, @NotNull Point end,
@NotNull Particle.DustOptions options) {
double dx = end.x - start.x;
double dy = end.y - start.y;
double dz = end.z - start.z;
double length = Math.sqrt(dx * dx + dy * dy + dz * dz);
int steps = Math.max(1, (int) (length / PARTICLE_STEP));
for (int i = 0; i <= steps; i++) {
double t = (double) i / steps;
double px = start.x + dx * t;
double py = start.y + dy * t;
double pz = start.z + dz * t;
player.spawnParticle(Particle.REDSTONE, px, py, pz, 1, options);
}
}
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
class BarrierGenerator extends ParkourGenerator {
private final BarrierRenderer renderer = new BarrierRenderer();
BarrierGenerator(@NotNull Session session) {
super(session);
}
@Override
public void generateFirst(@NotNull Location spawn, @NotNull Location block) {
super.generateFirst(spawn, block);
renderer.convertLastBlockToBarrier(state);
}
@Override
public void generate(int amount) {
super.generate(amount);
int start = Math.max(0, state.history.size() - amount);
for (int i = start; i < state.history.size(); i++) {
renderer.convertBlockToBarrier(state.history.get(i));
}
}
@Override
public void tick() {
super.tick();
renderer.tick(getPlayers());
}
@Override
public void reset(boolean regenerate) {
renderer.reset();
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.INVISIBLE_BARRIER;
}
}

View file

@ -0,0 +1,153 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.generator.core.model.GeneratorState;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
class BarrierRenderer {
private static final double RENDER_DISTANCE = 24.0;
private static final double PARTICLE_STEP = 0.1;
private final Set<Location> activeBarriers = new HashSet<>();
private final Random random = new Random();
private final Map<Location, Particle.DustOptions> blockColors = new HashMap<>();
private int particleTick = 0;
void convertLastBlockToBarrier(@NotNull GeneratorState state) {
if (state.history.isEmpty()) {
return;
}
convertBlockToBarrier(state.history.get(state.history.size() - 1));
}
void convertBlockToBarrier(@NotNull Block block) {
Location loc = block.getLocation();
if (block.getType() == Material.BARRIER) {
return;
}
block.setType(Material.BARRIER, false);
activeBarriers.add(loc);
Color color = Color.fromRGB(random.nextInt(256), random.nextInt(256), random.nextInt(256));
blockColors.put(loc, new Particle.DustOptions(color, 1.0f));
}
void tick(@NotNull List<ParkourPlayer> players) {
particleTick++;
if (particleTick % 4 == 0) {
renderOutlines(players);
}
}
void reset() {
activeBarriers.clear();
blockColors.clear();
particleTick = 0;
}
private void renderOutlines(@NotNull List<ParkourPlayer> players) {
if (activeBarriers.isEmpty()) {
return;
}
purgeStaleBarriers();
for (ParkourPlayer pp : players) {
renderForPlayer(pp.player, pp.player.getLocation());
}
}
private void purgeStaleBarriers() {
Iterator<Map.Entry<Location, Particle.DustOptions>> it = blockColors.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Location, Particle.DustOptions> entry = it.next();
if (entry.getKey().getBlock().getType() != Material.BARRIER) {
it.remove();
activeBarriers.remove(entry.getKey());
}
}
}
private void renderForPlayer(@NotNull Player player, @NotNull Location playerLoc) {
for (Location loc : activeBarriers) {
if (!isVisible(playerLoc, loc)) {
continue;
}
Particle.DustOptions options = blockColors.get(loc);
if (options == null) {
continue;
}
drawBlockOutline(player, loc, options);
}
}
private boolean isVisible(@NotNull Location playerLoc, @NotNull Location loc) {
return loc.getWorld() == playerLoc.getWorld()
&& loc.distanceSquared(playerLoc) <= RENDER_DISTANCE * RENDER_DISTANCE;
}
private record Point(double x, double y, double z) {
}
private void drawBlockOutline(@NotNull Player player, @NotNull Location loc, @NotNull Particle.DustOptions options) {
double x = loc.getX();
double y = loc.getY();
double z = loc.getZ();
Point p = new Point(x, y, z);
Point q = new Point(x + 1, y, z);
Point r = new Point(x + 1, y, z + 1);
Point s = new Point(x, y, z + 1);
drawEdge(player, p, q, options);
drawEdge(player, q, r, options);
drawEdge(player, r, s, options);
drawEdge(player, s, p, options);
Point pt = new Point(x, y + 1, z);
Point qt = new Point(x + 1, y + 1, z);
Point rt = new Point(x + 1, y + 1, z + 1);
Point st = new Point(x, y + 1, z + 1);
drawEdge(player, pt, qt, options);
drawEdge(player, qt, rt, options);
drawEdge(player, rt, st, options);
drawEdge(player, st, pt, options);
drawEdge(player, p, pt, options);
drawEdge(player, q, qt, options);
drawEdge(player, r, rt, options);
drawEdge(player, s, st, options);
}
private void drawEdge(@NotNull Player player, @NotNull Point start, @NotNull Point end,
@NotNull Particle.DustOptions options) {
double dx = end.x - start.x;
double dy = end.y - start.y;
double dz = end.z - start.z;
double length = Math.sqrt(dx * dx + dy * dy + dz * dz);
int steps = Math.max(1, (int) (length / PARTICLE_STEP));
for (int i = 0; i <= steps; i++) {
double t = (double) i / steps;
double px = start.x + dx * t;
double py = start.y + dy * t;
double pz = start.z + dz * t;
player.spawnParticle(Particle.REDSTONE, px, py, pz, 1, options);
}
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Item;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class InvisibleBarrierMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "invisible-barrier";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.invisible-barrier");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!ModeMessages.checkJoiningEnabled(player)) {
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof BarrierGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new BarrierGenerator(session), null, null, player);
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -2,19 +2,14 @@ package dev.loki.loparkour.player.core;
import com.google.gson.annotations.Expose;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.player.service.ParkourHotbar;
import dev.loki.loparkour.player.service.PlayerSettingsManager;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.core.Divider;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
@ -22,10 +17,8 @@ import org.bukkit.potion.PotionEffect;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
/**
@ -37,9 +30,6 @@ public class ParkourPlayer extends ParkourUser {
public static final Map<String, PlayerSettingsManager.OptionContainer> PLAYER_COLUMNS = PlayerSettingsManager.getColumnMappings();
// Track scored blocks to prevent duplicate scoring (String key = "x,y,z" to avoid Bukkit Block reference equality)
private final Set<String> scoredBlocks = new HashSet<>();
public @Expose Double schematicDifficulty;
public @Expose Integer blockLead;
public @Expose Boolean particles;
@ -51,6 +41,11 @@ public class ParkourPlayer extends ParkourUser {
public @Expose String style;
public @Expose String locale;
public @Expose List<String> collectedRewards;
private final PlayerScoringTracker scoring = new PlayerScoringTracker();
private final PlayerPersistence persistence = new PlayerPersistence();
private final PlayerSpawnSetup spawnSetup = new PlayerSpawnSetup();
/**
* Creates a new instance of a ParkourPlayer<br>
* If you are using the API, please use {@link ParkourPlayer#register(Player, Session)} instead
@ -107,36 +102,32 @@ public class ParkourPlayer extends ParkourUser {
}
session.removePlayers(this);
// Clear scored blocks to prevent memory leak
scoredBlocks.clear();
save(LoParkour.getPlugin().isEnabled());
// Clear scored blocks to prevent memory leak
scoring.clear();
persistence.save(this, LoParkour.getPlugin().isEnabled());
}
/**
* Clear scored blocks set, typically on fall/respawn so same coordinates can be re-scored.
*/
public void clearScoredBlocks() {
scoredBlocks.clear();
}
private static String blockKey(Block block) {
return block.getX() + "," + block.getY() + "," + block.getZ();
scoring.clear();
}
/**
* Check if player has already scored on this block.
*/
public boolean hasScored(Block block) {
return scoredBlocks.contains(blockKey(block));
return scoring.hasScored(block);
}
/**
* Mark block as scored to prevent duplicate scoring.
*/
public void markScored(Block block) {
scoredBlocks.add(blockKey(block));
scoring.markScored(block);
}
/**
@ -172,36 +163,20 @@ public class ParkourPlayer extends ParkourUser {
* Saves the player's data to their file
*/
public void save(boolean async) {
Runnable write = () -> Storage.writePlayer(this);
if (async) {
Scheduler.get(LoParkour.getPlugin()).runAsync(write);
} else {
write.run();
}
persistence.save(this, async);
}
public void setup(Location to) {
if (to != null) {
teleport(to);
}
player.setGameMode(GameMode.ADVENTURE);
if (Config.CONFIG.getBoolean("options.inventory-handling")) {
Scheduler.get(LoParkour.getPlugin()).runLater(() -> ParkourHotbar.apply(this), 5);
} else {
sendTranslated("other.customize");
}
spawnSetup.setup(this, to);
}
/**
* Updates hotbar items with current language
*/
public void updateHotbar() {
ParkourHotbar.apply(this);
spawnSetup.updateHotbar(this);
}
public record OptionContainer(ParkourOption option, BiConsumer<ParkourPlayer, String> consumer) {
}
}
}

View file

@ -0,0 +1,21 @@
package dev.loki.loparkour.player.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.storage.Storage;
import dev.lolib.scheduler.Scheduler;
/**
* Handles persistence of player data to the configured storage backend.
*/
final class PlayerPersistence {
void save(ParkourPlayer player, boolean async) {
Runnable write = () -> Storage.writePlayer(player);
if (async) {
Scheduler.get(LoParkour.getPlugin()).runAsync(write);
} else {
write.run();
}
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.loparkour.player.core;
import org.bukkit.block.Block;
import java.util.HashSet;
import java.util.Set;
/**
* Tracks the blocks a player has already scored on to prevent duplicate scoring.
*/
final class PlayerScoringTracker {
// Track scored blocks to prevent duplicate scoring (String key = "x,y,z" to avoid Bukkit Block reference equality)
private final Set<String> scoredBlocks = new HashSet<>();
boolean hasScored(Block block) {
return scoredBlocks.contains(blockKey(block));
}
void markScored(Block block) {
scoredBlocks.add(blockKey(block));
}
void clear() {
scoredBlocks.clear();
}
private static String blockKey(Block block) {
return block.getX() + "," + block.getY() + "," + block.getZ();
}
}

View file

@ -0,0 +1,32 @@
package dev.loki.loparkour.player.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.player.service.ParkourHotbar;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.GameMode;
import org.bukkit.Location;
/**
* Prepares a parkour player's world state such as location, game mode and hotbar.
*/
final class PlayerSpawnSetup {
void setup(ParkourPlayer player, Location to) {
if (to != null) {
player.teleport(to);
}
player.player.setGameMode(GameMode.ADVENTURE);
if (Config.CONFIG.getBoolean("options.inventory-handling")) {
Scheduler.get(LoParkour.getPlugin()).runLater(() -> ParkourHotbar.apply(player), 5);
} else {
player.sendTranslated("other.customize");
}
}
void updateHotbar(ParkourPlayer player) {
ParkourHotbar.apply(player);
}
}

View file

@ -19,6 +19,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Level;
/**
* Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from {@code plugins/LoParkour/schematics/}.
@ -100,7 +101,7 @@ public final class SchematicManager {
ok++;
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to load structure: " + file.getName());
e.printStackTrace();
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
}
}
return ok;
@ -126,7 +127,7 @@ public final class SchematicManager {
ok++;
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to load schematic: " + file.getName());
e.printStackTrace();
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
}
}
return ok;

View file

@ -0,0 +1,63 @@
package dev.loki.loparkour.storage.sql;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.logging.Logger;
/**
* CRUD for player data.
*/
class SQLPlayerRepository {
private static final Logger LOGGER = Logger.getLogger(SQLPlayerRepository.class.getName());
private SQLPlayerRepository() {
}
static void readPlayer(@NotNull SQLConnectionManager manager, @NotNull ParkourPlayer player) {
if (!manager.isConnected()) {
return;
}
String query = SQLQueryBuilder.selectPlayer();
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, player.player.getUniqueId().toString());
try (ResultSet rs = stmt.executeQuery()) {
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
if (data != null) {
SQLDataMapper.applyPlayerData(player, data);
}
}
} catch (SQLException e) {
LOGGER.severe("Failed to read player data: " + e.getMessage());
}
}
static void writePlayer(@NotNull SQLConnectionManager manager, @NotNull ParkourPlayer player) {
if (!manager.isConnected()) {
return;
}
String query = SQLQueryBuilder.upsertPlayer();
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
SQLDataMapper.setPlayerParameters(stmt, player);
stmt.executeUpdate();
} catch (SQLException e) {
LOGGER.severe("Failed to write player data: " + e.getMessage());
}
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.storage.sql;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* Creates the database schema (DDL) for parkour storage.
*/
class SQLSchemaManager {
private static final String ADAPTIVE_STATS_TABLE = """
CREATE TABLE IF NOT EXISTS loparkour_player_stats (
player_uuid VARCHAR(36) PRIMARY KEY,
skill_rating DOUBLE DEFAULT 1.0,
sessions_count INT DEFAULT 0,
total_jumps INT DEFAULT 0,
total_falls INT DEFAULT 0,
longest_streak INT DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_skill_rating (skill_rating),
INDEX idx_last_updated (last_updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""";
private SQLSchemaManager() {
}
static void createTables(@NotNull SQLConnectionManager manager, @NotNull String mode) throws SQLException {
String tableName = SQLQueryBuilder.getTableName(mode);
try (Connection conn = manager.getConnection()) {
createScoresTable(conn, tableName);
createPlayersTable(conn);
createAdaptiveStatsTable(conn);
}
}
private static void createScoresTable(@NotNull Connection conn, @NotNull String tableName) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
stmt.executeUpdate();
}
}
private static void createPlayersTable(@NotNull Connection conn) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
stmt.executeUpdate();
}
}
private static void createAdaptiveStatsTable(@NotNull Connection conn) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(ADAPTIVE_STATS_TABLE)) {
stmt.executeUpdate();
}
}
}

View file

@ -0,0 +1,68 @@
package dev.loki.loparkour.storage.sql;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;
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;
/**
* CRUD for leaderboard scores.
*/
class SQLScoreRepository {
private static final Logger LOGGER = Logger.getLogger(SQLScoreRepository.class.getName());
private SQLScoreRepository() {
}
@NotNull
static Map<UUID, Score> readScores(@NotNull SQLConnectionManager manager, @NotNull String mode) {
if (!manager.isConnected()) {
return Map.of();
}
String query = SQLQueryBuilder.selectScores(SQLQueryBuilder.getTableName(mode));
try (Connection conn = manager.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();
}
}
static void writeScores(@NotNull SQLConnectionManager manager,
@NotNull String mode,
@NotNull Map<UUID, Score> scores) {
if (!manager.isConnected() || scores.isEmpty()) {
return;
}
String query = SQLQueryBuilder.upsertScore(SQLQueryBuilder.getTableName(mode));
try (Connection conn = manager.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());
}
}
}

View file

@ -4,17 +4,14 @@ import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
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;
/**
* Refactored StorageSQL using composition pattern.
* Coordinates connection management, query building, and data mapping.
* Facade for SQL storage. Coordinates connection lifecycle and delegates
* CRUD to dedicated repository classes.
*/
public class StorageSQL {
@ -23,7 +20,7 @@ public class StorageSQL {
private static final Logger LOGGER = Logger.getLogger(StorageSQL.class.getName());
private static SQLConnectionManager connectionManager;
/**
* Check if database is connected.
*/
@ -38,7 +35,7 @@ public class StorageSQL {
}
return connectionManager;
}
/**
* Run callback when database is connected.
*/
@ -49,7 +46,7 @@ public class StorageSQL {
LOGGER.warning("Database not connected, skipping operation");
}
}
/**
* Close database connection.
*/
@ -59,7 +56,7 @@ public class StorageSQL {
connectionManager = null;
}
}
/**
* Initialize database connection and tables.
*/
@ -67,15 +64,15 @@ public class StorageSQL {
try {
connectionManager = new SQLConnectionManager();
connectionManager.connect();
createTables(mode);
SQLSchemaManager.createTables(connectionManager, 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.
*/
@ -84,22 +81,9 @@ public class StorageSQL {
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();
}
return SQLScoreRepository.readScores(connectionManager, mode);
}
/**
* Write scores for specific mode.
*/
@ -107,26 +91,9 @@ public class StorageSQL {
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());
}
SQLScoreRepository.writeScores(connectionManager, mode, scores);
}
/**
* Read player data from database.
*/
@ -134,26 +101,9 @@ public class StorageSQL {
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()) {
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
if (data != null) {
SQLDataMapper.applyPlayerData(player, data);
}
}
} catch (SQLException e) {
LOGGER.severe("Failed to read player data: " + e.getMessage());
}
SQLPlayerRepository.readPlayer(connectionManager, player);
}
/**
* Write player data to database.
*/
@ -161,49 +111,6 @@ public class StorageSQL {
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());
}
SQLPlayerRepository.writePlayer(connectionManager, player);
}
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();
}
// Adaptive player stats
try (PreparedStatement stmt = conn.prepareStatement(
"CREATE TABLE IF NOT EXISTS loparkour_player_stats ("
+ "player_uuid VARCHAR(36) PRIMARY KEY, "
+ "skill_rating DOUBLE DEFAULT 1.0, "
+ "sessions_count INT DEFAULT 0, "
+ "total_jumps INT DEFAULT 0, "
+ "total_falls INT DEFAULT 0, "
+ "longest_streak INT DEFAULT 0, "
+ "last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+ "INDEX idx_skill_rating (skill_rating), "
+ "INDEX idx_last_updated (last_updated)"
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")) {
stmt.executeUpdate();
}
}
}
}
}